diff --git a/amethyst/plans/2026-07-24-nwc-bolt12-pay.md b/amethyst/plans/2026-07-24-nwc-bolt12-pay.md index 37e0a30b8c..e42b00a729 100644 --- a/amethyst/plans/2026-07-24-nwc-bolt12-pay.md +++ b/amethyst/plans/2026-07-24-nwc-bolt12-pay.md @@ -1,6 +1,6 @@ # NWC BOLT12 payments (nostr-wallet-connect/nwc#2) -Status: **scoping** — no code yet. Depends on NIP-2421 (this branch) and the +Status: **scoping** — no code yet. Depends on NIP-B1 (this branch) and the unmerged `nostr-wallet-connect/nwc#2` (adds `pay`/`receive` to NIP-47). ## Ecosystem status (2026-07-25) — the wallet gap for real testing @@ -10,7 +10,7 @@ A real NWC-321 (`pay`/`receive`) service exists — — which confirms the protocol we built (Phase 0) is real and adopted. **But it is LND-backed:** its `pay` "selects and pays a BOLT11 `lightning` instruction from a BIP-321 URI" and "BOLT12 `lno` instructions are not supported by this LND backend." -So it returns no BOLT12 invoice and no `payer_proof`, and can't drive the NIP-2421 +So it returns no BOLT12 invoice and no `payer_proof`, and can't drive the NIP-B1 zap loop. The blocker is the **node backend**, not the protocol: a CLN- or LDK-backed NWC-321 service (CLN has full BOLT12 and leads the payer-proof draft bolts#1346) would support `lno` + return `payer_proof`. Until one exists, the @@ -97,7 +97,7 @@ but is **not** wired into the pay path — we'd add the gate ourselves. kind-10058 offer and the wallet supports `pay`, offer a BOLT12 zap. 1. Build + sign kind-9737 intent (amount, offer, p, e/a/k, zap_id, content). 2. `pay` with `payment=bitcoin:?lno=`, `amount`, - `payer_note="nostr:nipXX:"`. + `payer_note="nostr:nipB1:"`. 3. On success with a `payer_proof`, `Bolt12ZapEvent.build(intent, payerProof, payerPubKey = own | null for anon)`, sign, publish to the recipient's inbox relays. @@ -114,8 +114,8 @@ but is **not** wired into the pay path — we'd add the gate ourselves. 1. **`payer_note` → `invreq_payer_note` is NOT guaranteed by nwc#2.** The spec only says "if `payer_note` is not empty, the selected instruction MUST support payer-provided messages" — it never states the note lands in the BOLT12 - `invreq_payer_note`. NIP-2421 binds the zap to the intent through exactly that - field (`invreq_payer_note == nostr:nipXX:`). If a wallet routes + `invreq_payer_note`. NIP-B1 binds the zap to the intent through exactly that + field (`invreq_payer_note == nostr:nipB1:`). If a wallet routes `payer_note` elsewhere, the returned `payer_proof` fails our validator and the 9736 is worthless. **Phase 2 feasibility hinges on this** — needs confirmation in the nwc thread / a reference wallet, or a follow-up to nwc#2 to nail it down. @@ -129,7 +129,7 @@ but is **not** wired into the pay path — we'd add the gate ourselves. `quartz/plans/2026-07-23-bolt12-zap-interop-vectors.md`). Real wallet (selective-disclosure) proofs now reconstruct and verify, so a bound zap counts locally as `cryptoVerified = true`. -4. **Maturity.** Both nwc#2 and NIP-2421 are unmerged; few/no wallets implement +4. **Maturity.** Both nwc#2 and NIP-B1 are unmerged; few/no wallets implement `pay` today. Gate hard on capability (Phase 3) and keep the intent fallback. ## Resolution of risks #1/#2 (nwc#2 maintainer, 2026-07-24) @@ -138,7 +138,7 @@ Asked on the nwc#2 thread. Maintainer confirmed: - **`payer_note` → `invreq_payer_note`**: "that is the intention" for BOLT12 (the field doubles as a general memo). So our zap binding - (`invreq_payer_note == nostr:nipXX:`) is the intended target. + (`invreq_payer_note == nostr:nipB1:`) is the intended target. - **`payer_proof`**: "should be returned for successful bolt12 payments"; the "optional if unavailable" wording only covers non-BOLT12 instruction types. @@ -160,7 +160,7 @@ Chosen: full integration into the zap pipeline, preferring BOLT12 when the recipient offers it. - `Account.sendBolt12Zap(...)` — signs a 9737 intent (ephemeral key for anonymous), - pays over NWC with `payer_note = nostr:nipXX:`, and only on a proof + pays over NWC with `payer_note = nostr:nipB1:`, and only on a proof that passes `Bolt12ZapValidator` self-consumes + publishes the 9736 via `computeRelayListToBroadcast`. No proof / invalid proof → "paid, no receipt". - `ZapPaymentHandler.zap()` resolves each recipient's kind:10058 offer and diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/components/AudioPlayerBoxOverflowTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/components/AudioPlayerBoxOverflowTest.kt new file mode 100644 index 0000000000..564715b2f9 --- /dev/null +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/components/AudioPlayerBoxOverflowTest.kt @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.unit.dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.vitorpamplona.amethyst.service.playback.composable.audioSquare +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * The inline audio player sizes itself as a square so the visualizer and controls get room, which is + * taller than the 16:9 box [ZoomableContentView] builds for a video of unknown dimensions. That box + * is centre-aligned and nothing between it and NoteComposeLayout clips, so caging the square in it + * makes the player paint over the note header above and the reactions row below. + * + * These tests recreate that enclosure — the real [mediaSizingModifier] over the real + * [unknownMediaAspectRatio], holding the real [audioSquare] — and pin both halves of the contract: + * audio must not be given a ratio, and video must keep the 16:9 assumption that stops live streams + * from letterboxing on first play. + */ +@RunWith(AndroidJUnit4::class) +class AudioPlayerBoxOverflowTest { + @get:Rule val rule = createComposeRule() + + private class Bounds { + var top = 0f + var bottom = 0f + var width = 0 + var height = 0 + + fun modifier() = + Modifier.onGloballyPositioned { + top = it.positionInRoot().y + width = it.size.width + height = it.size.height + bottom = top + height + } + } + + private fun measure( + url: String, + square: Boolean, + ): Pair { + val box = Bounds() + val player = Bounds() + + rule.setContent { + Box(Modifier.size(width = 400.dp, height = 1200.dp)) { + Box( + modifier = mediaSizingModifier(unknownMediaAspectRatio(null, url), ContentScale.Fit).then(box.modifier()), + contentAlignment = Alignment.Center, + ) { + Box((if (square) Modifier.audioSquare() else Modifier).then(player.modifier())) + } + } + } + rule.waitForIdle() + + return box to player + } + + @Test + fun squareAudioPlayerStaysInsideItsMediaBox() { + val (box, player) = measure("https://haven.sdbitcoiners.com/f28a5a2e.mp3", square = true) + + assertTrue( + "player overflows above the media box by ${box.top - player.top}px", + player.top >= box.top, + ) + assertTrue( + "player overflows below the media box by ${player.bottom - box.bottom}px", + player.bottom <= box.bottom, + ) + assertEquals("the box should wrap the square", player.height, box.height) + } + + @Test + fun videoOfUnknownSizeKeeps16by9() { + val (box, _) = measure("https://example.com/a.mp4", square = false) + + assertEquals(16f / 9f, box.width.toFloat() / box.height, 0.01f) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index ef75600b76..72512be6ae 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -70,7 +70,7 @@ import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent -import com.vitorpamplona.quartz.nipXXBolt12Zaps.offer.Bolt12OfferListEvent +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers @@ -206,6 +206,7 @@ private object PrefKeys { const val SIGNER_PACKAGE_NAME = "signer_package_name" const val HAS_DONATED_IN_VERSION = "has_donated_in_version" const val DISMISSED_POLL_NOTE_IDS = "dismissed_poll_note_ids" + const val DISMISSED_CHANNEL_INVITES = "dismissed_channel_invites" const val VIEWED_POLL_RESULT_NOTE_IDS = "viewed_poll_result_note_ids" const val PENDING_ATTESTATIONS = "pending_attestations" @@ -630,6 +631,7 @@ object LocalPreferences { ) putStringSet(PrefKeys.HAS_DONATED_IN_VERSION, settings.hasDonatedInVersion.value) putStringSet(PrefKeys.DISMISSED_POLL_NOTE_IDS, settings.dismissedPollNoteIds.value) + putStringSet(PrefKeys.DISMISSED_CHANNEL_INVITES, settings.dismissedChannelInvites.value) putString( PrefKeys.VIEWED_POLL_RESULT_NOTE_IDS, JsonMapper.toJson(settings.viewedPollResultNoteIds.value), @@ -744,6 +746,7 @@ object LocalPreferences { val showMessagesInNotifications = getBoolean(PrefKeys.SHOW_MESSAGES_IN_NOTIFICATIONS, true) val hasDonatedInVersion = getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null) ?: setOf() val dismissedPollNoteIds = getStringSet(PrefKeys.DISMISSED_POLL_NOTE_IDS, null) ?: setOf() + val dismissedChannelInvites = getStringSet(PrefKeys.DISMISSED_CHANNEL_INVITES, null) ?: setOf() val viewedPollResultNoteIdsStr = getString(PrefKeys.VIEWED_POLL_RESULT_NOTE_IDS, null) val localRelayServers = getStringSet(PrefKeys.LOCAL_RELAY_SERVERS, null) ?: setOf() @@ -1000,6 +1003,7 @@ object LocalPreferences { lastReadPerRoute = MutableStateFlow(lastReadPerRouteResolved), hasDonatedInVersion = MutableStateFlow(hasDonatedInVersion), dismissedPollNoteIds = MutableStateFlow(dismissedPollNoteIds), + dismissedChannelInvites = MutableStateFlow(dismissedChannelInvites), viewedPollResultNoteIds = MutableStateFlow(viewedPollResultNoteIdsResolved), pendingAttestations = MutableStateFlow(pendingAttestationsResolved), backupNipA3PaymentTargets = latestPaymentTargetsResolved, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index f31bfb07c0..315f3801fd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -168,7 +168,6 @@ import com.vitorpamplona.quartz.buzz.jobs.JobRequestEvent import com.vitorpamplona.quartz.buzz.presence.TypingIndicatorEvent import com.vitorpamplona.quartz.buzz.relayAdmin.RelayAdminAddMemberEvent import com.vitorpamplona.quartz.buzz.relayAdmin.RelayAdminRemoveMemberEvent -import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event import com.vitorpamplona.quartz.buzz.threading.buzzThread import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply import com.vitorpamplona.quartz.buzz.threading.buzzThreadRoot @@ -380,9 +379,9 @@ import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.builder.Bolt12ZapBuilder +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.verify.Bolt12ZapValidation import com.vitorpamplona.quartz.nipC7Chats.ChatEvent -import com.vitorpamplona.quartz.nipXXBolt12Zaps.builder.Bolt12ZapBuilder -import com.vitorpamplona.quartz.nipXXBolt12Zaps.verify.Bolt12ZapValidation import com.vitorpamplona.quartz.utils.DualCase import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.RandomInstance @@ -1468,7 +1467,7 @@ class Account( } /** - * Sends a NIP-XX BOLT12 zap to [recipientPubKey] over the default NWC wallet. + * Sends a NIP-B1 BOLT12 zap to [recipientPubKey] over the default NWC wallet. * * Signs a kind 9737 intent, pays [offer] via the nwc#2 `pay` method with the * intent-bound `payer_note`, then — only if the wallet returns a payer proof that @@ -2450,12 +2449,24 @@ class Account( val hostRelay = group.groupId.relayUrl val signed = if (BuzzRelayDialect.isBuzz(hostRelay)) { - // Buzz rejects kind-1111, so its minichat threads with a 40002 marked at the message's - // root (never `broadcast` — a minichat reply always lives in the thread). Attached - // media is carried as URLs appended to the content (no `imeta` on the stream event). + // Buzz rejects kind-1111, so its minichat threads with a NIP-10 `reply`-marked `e` + // on a plain kind-9 chat — byte-identical to `_buildReplyTags` in Buzz's own client + // (direct reply -> one `reply` marker; nested -> `root` + `reply`), which is what + // [buzzThread] emits. + // + // This used to write kind-40002. Nothing in Buzz writes 40002 any more — every send + // path in their mobile, desktop and CLI clients emits kind 9, and their NOSTR.md + // grades 40002 "Buzz-only — no standard NIP-29 client renders these" against kind 9's + // blessed status. 40002 survives only as a read-compat tail from the + // 10002 -> 40001 -> 40002 migration, so we were the last active writer of a kind + // their clients no longer thread on. Reading 40002 stays supported (see + // [com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.isMinichatReply]). + // + // Attached media rides as URLs appended to the content. val root = rootEvent.tags.buzzThreadRoot() ?: rootEvent.tags.buzzThreadReply() ?: rootEvent.id signer.sign( - StreamMessageV2Event.build(group.groupId.id, finalText) { + ChatEvent.build(finalText) { + hTag(group.groupId.id) buzzThread(root, rootEvent.id) rootNote.author?.pubkeyHex?.let { pTag(PTag(it)) } previous(group.previousEventRefs(pubKey)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index cf62e2454c..2b11fb7039 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -75,7 +75,7 @@ import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent -import com.vitorpamplona.quartz.nipXXBolt12Zaps.offer.Bolt12OfferListEvent +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.MutableStateFlow @@ -320,6 +320,12 @@ class AccountSettings( val lastReadPerRoute: MutableStateFlow>> = MutableStateFlow(mapOf()), val hasDonatedInVersion: MutableStateFlow> = MutableStateFlow(setOf()), val dismissedPollNoteIds: MutableStateFlow> = MutableStateFlow(setOf()), + /** + * Channel ids the viewer chose NOT to show on Messages after somebody added them to the channel + * (kind-44100). Local-only: it records a display preference, not membership — the relay roster + * still lists you, and Leave (kind 9022) is the separate action that actually removes you. + */ + val dismissedChannelInvites: MutableStateFlow> = MutableStateFlow(setOf()), val viewedPollResultNoteIds: MutableStateFlow> = MutableStateFlow(mapOf()), val pendingAttestations: MutableStateFlow> = MutableStateFlow(mapOf()), var backupNipA3PaymentTargets: PaymentTargetsEvent? = null, @@ -1521,6 +1527,27 @@ class AccountSettings( } } + // --- + // dismissed channel invites (somebody added me to a channel; I don't want it on Messages) + // --- + + fun isDismissedChannelInvite(channelId: String) = dismissedChannelInvites.value.contains(channelId) + + fun dismissChannelInvite(channelId: String) { + if (!dismissedChannelInvites.value.contains(channelId)) { + dismissedChannelInvites.update { it + channelId } + saveAccountSettings() + } + } + + /** Undo a dismissal — used when the viewer accepts the channel after all, so it can re-prompt later. */ + fun undismissChannelInvite(channelId: String) { + if (dismissedChannelInvites.value.contains(channelId)) { + dismissedChannelInvites.update { it - channelId } + saveAccountSettings() + } + } + // --- // pinned chatrooms // --- 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 f90763b71d..9ef7f5122d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.cashu.MintDirectoryIndex import com.vitorpamplona.amethyst.commons.model.Channel import com.vitorpamplona.amethyst.commons.model.OnchainZapStatus +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvites import com.vitorpamplona.amethyst.commons.model.buzz.BuzzCommunityMembership import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmRegistry import com.vitorpamplona.amethyst.commons.model.buzz.BuzzPresenceState @@ -391,6 +392,10 @@ import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRejectEvent import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.verify.Bolt12ZapValidation +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.verify.Bolt12ZapValidator +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.OnchainBackend import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent @@ -400,10 +405,6 @@ import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent -import com.vitorpamplona.quartz.nipXXBolt12Zaps.offer.Bolt12OfferListEvent -import com.vitorpamplona.quartz.nipXXBolt12Zaps.verify.Bolt12ZapValidation -import com.vitorpamplona.quartz.nipXXBolt12Zaps.verify.Bolt12ZapValidator -import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent import com.vitorpamplona.quartz.utils.DualCase @@ -472,7 +473,7 @@ object LocalCache : ILocalCache, ICacheProvider { val onchainZapResolver = OnchainZapResolver(this) /** - * NIP-XX BOLT12 zap validator. Unlike onchain zaps, BOLT12 proof verification + * NIP-B1 BOLT12 zap validator. Unlike onchain zaps, BOLT12 proof verification * is synchronous (a self-contained `lnp` payer proof), so `consume(Bolt12ZapEvent)` * validates inline and needs no async resolver. */ @@ -1293,7 +1294,7 @@ object LocalCache : ILocalCache, ICacheProvider { } is Bolt12ZapEvent -> { - // NIP-XX BOLT12 zaps target an event (e), an addressable event (a), + // NIP-B1 BOLT12 zaps target an event (e), an addressable event (a), // or just the recipient profile (p) — same shape as onchain zaps. buildList { event.zappedEvent()?.let { checkGetOrCreateNote(it)?.let { add(it) } } @@ -2292,6 +2293,22 @@ object LocalCache : ILocalCache, ICacheProvider { } } + /** + * A kind-44101 "you were removed from a channel". Consumed like any other Buzz event, then used to + * withdraw any pending add-prompt for that channel: once the relay has taken the membership away + * there is nothing left to accept, so leaving the card up would offer an action that cannot succeed. + */ + private fun consume( + event: MemberRemovedNotificationEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ): Boolean = + consumeBuzzRegularEvent(event, relay, wasVerified).also { + val target = event.target() ?: return@also + val channelId = event.channel() ?: return@also + BuzzChannelInvites.remove(target, channelId) + } + /** * Attach a group-scoped content event (a kind-9 chat, kind-1068 poll, … * carrying an `h` tag) to its [RelayGroupChannel]. NIP-29 reuses the generic @@ -2701,7 +2718,7 @@ object LocalCache : ILocalCache, ICacheProvider { if (!(wasVerified || justVerify(event))) return false - // NIP-XX validation is fully synchronous: zap-event structure, the embedded + // NIP-B1 validation is fully synchronous: zap-event structure, the embedded // kind:9737 intent match, and the `lnp` payer-proof binding + crypto. A failed // validation drops the zap entirely — it never contributes to a zap total. // The outer event signature was already verified above (wasVerified/justVerify), @@ -4821,7 +4838,7 @@ object LocalCache : ILocalCache, ICacheProvider { is DmAddMemberEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) is DmHideEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) is MemberAddedNotificationEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is MemberRemovedNotificationEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) + is MemberRemovedNotificationEvent -> consume(event, relay, wasVerified) is RelayMembershipListEvent -> consume(event, relay, wasVerified) is RelayAddMemberEvent -> consume(event, relay, wasVerified) is RelayRemoveMemberEvent -> consume(event, relay, wasVerified) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/bolt12Offers/Bolt12OfferListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/bolt12Offers/Bolt12OfferListState.kt index d32b024393..f73f7aff7c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/bolt12Offers/Bolt12OfferListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/bolt12Offers/Bolt12OfferListState.kt @@ -24,7 +24,7 @@ import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.NoteState import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nipXXBolt12Zaps.offer.Bolt12OfferListEvent +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi @@ -37,7 +37,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch /** - * The logged-in user's NIP-XX BOLT12 offer list (kind 10058) as live account state, + * The logged-in user's NIP-B1 BOLT12 offer list (kind 10058) as live account state, * mirroring [com.vitorpamplona.amethyst.model.nipA3PaymentTargets.NipA3PaymentTargetsState]. * Exposes the current offers as a [flow], persists them across restarts (via * [AccountSettings]), and publishes updates with [saveOffers]. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt index 953fb23913..6fa29d1b5e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt @@ -77,7 +77,7 @@ class ZapPaymentHandler( val user: User? = null, ) - /** A recipient routed over BOLT12 (NIP-XX): they publish a kind:10058 [offer] and we hold an NWC wallet. */ + /** A recipient routed over BOLT12 (NIP-B1): they publish a kind:10058 [offer] and we hold an NWC wallet. */ data class Bolt12Recipient( val user: User, val offer: String, @@ -442,7 +442,7 @@ class ZapPaymentHandler( } /** - * BOLT12 zap rail (NIP-XX). For each recipient that publishes a kind:10058 offer, + * BOLT12 zap rail (NIP-B1). For each recipient that publishes a kind:10058 offer, * signs a 9737 intent, pays the offer over NWC with the intent-bound `payer_note`, * and (if the returned proof validates) publishes a 9736 zap — see * [Account.sendBolt12Zap]. Fire-and-forget like [payViaNWC]: dispatch is optimistic diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt index 9deda637b1..9baba637d9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt @@ -61,9 +61,9 @@ import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent import com.vitorpamplona.quartz.nipC7Chats.ChatEvent -import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.CancellationException diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt index 8299f85493..f45feb7d97 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt @@ -52,8 +52,8 @@ import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent -import com.vitorpamplona.quartz.nipXXBolt12Zaps.offer.Bolt12OfferListEvent val AccountInfoAndListsFromKeyKinds = listOf( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt index 06d29d6bbd..c73b0dd9c0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt @@ -20,8 +20,6 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications -import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmChannels -import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmRegistry import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState @@ -29,7 +27,6 @@ import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job @@ -84,45 +81,17 @@ class AccountNotificationsEoseFromInboxRelaysManager( ) } - // NIP-29 group activity (reactions/replies to my messages) lives on the group's host relay, - // not my inbox relays — poll each host relay for those, scoped by `#h` to my joined groups. - val groups = - key.account.relayGroupList.liveRelayGroupList.value - .groupBy({ it.relayUrl }, { it.groupId }) - .flatMap { (relayUrl, groupIds) -> - val relay = RelayUrlNormalizer.normalizeOrNull(relayUrl) ?: return@flatMap emptyList() - filterGroupNotificationsToPubkey( - relay = relay, - pubkey = user(key).pubkeyHex, - groupIds = groupIds.distinct(), - // Same reasoning as the inbox filters above: `#p` + `#h` + `limit = 200` - // already bound this, so a week floor only hides older group activity. - since = since?.get(relay)?.time ?: pagingBoundary, - ) - } - - // Buzz DM channels are NOT in the published group list (membership is server-side, tracked in - // BuzzDmChannels), so the joined-group query above skips them. Poll each DM host relay the same - // way — `#p` = me + `#h` = my DM channels — so a reaction/zap/repost on my DM message surfaces - // in notifications, not only as a chip inside the open conversation. Hidden DMs are excluded. - val myPubkey = user(key).pubkeyHex - val hiddenDms = BuzzDmRegistry.hiddenFor(myPubkey) - val dmGroups = - BuzzDmChannels - .channelsFor(myPubkey) - .filterKeys { it !in hiddenDms } - .entries - .groupBy({ it.value }, { it.key }) - .flatMap { (relay, channelIds) -> - filterGroupNotificationsToPubkey( - relay = relay, - pubkey = myPubkey, - groupIds = channelIds.distinct(), - since = since?.get(relay)?.time ?: pagingBoundary, - ) - } - - return inbox + groups + dmGroups + // NIP-29 group activity (reactions/replies to my messages) is deliberately NOT requested here. + // It lives on the group's host relay and used to be one `#h` filter per relay carrying every + // joined group id — but this subscription also carries the inbox filters above, which have no + // `#h` at all, and `block/buzz` downgrades any subscription with a channel-less (or multi- + // channel) filter to "global", a class that by design never receives channel-scoped events. So + // those filters answered the stored query at EOSE and then went deaf until the next launch. + // + // Group and Buzz-DM activity now rides the per-channel subscriptions that are already scoped to + // exactly one channel — RelayGroupJoinedChatTailSubAssembler and BuzzDmJoinedChatTailSubAssembler, + // both mounted app-wide from LoggedInPage, so coverage is unchanged and delivery is live. + return inbox } val userJobMap = mutableMapOf>() @@ -138,21 +107,8 @@ class AccountNotificationsEoseFromInboxRelaysManager( invalidateFilters() } }, - // Re-subscribe when I join/leave a group so its host relay is added to (or dropped - // from) the notification query. - key.account.scope.launch(Dispatchers.IO) { - key.account.relayGroupList.liveRelayGroupList.sample(1000).collectLatest { - invalidateFilters() - } - }, - // Re-subscribe when a Buzz DM is discovered/hidden so its host relay is polled for - // reactions/zaps on my DM messages. - key.account.scope.launch(Dispatchers.IO) { - BuzzDmChannels.flow.sample(1000).collectLatest { invalidateFilters() } - }, - key.account.scope.launch(Dispatchers.IO) { - BuzzDmRegistry.hidden.sample(1000).collectLatest { invalidateFilters() } - }, + // No group/Buzz-DM watchers here any more: those filters moved to the per-channel + // subscriptions, which mount and unmount with the channel itself. key.account.scope.launch(Dispatchers.IO) { key.feedContentStates.notifications.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest { invalidateFilters() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt index 1842233e05..257f535dd2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt @@ -56,9 +56,9 @@ import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent import com.vitorpamplona.quartz.nipC7Chats.ChatEvent -import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent /** * Kinds that notify me about activity on MY messages inside a NIP-29 group — reactions, replies diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToAddresses.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToAddresses.kt index 8041f4f964..45296e8e5f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToAddresses.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToAddresses.kt @@ -37,7 +37,7 @@ import com.vitorpamplona.quartz.nip56Reports.ReportEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent -import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.utils.mapOfSet val RepliesAndReactionsToAddressesKinds1 = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt index 7d620418f2..8d951f5de9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt @@ -43,8 +43,8 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent -import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.utils.mapOfSet val RepliesAndReactionsKinds = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt index 294ca849df..24295084b5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt @@ -35,7 +35,7 @@ import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent import com.vitorpamplona.quartz.nip39ExtIdentities.ExternalIdentitiesEvent import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent -import com.vitorpamplona.quartz.nipXXBolt12Zaps.offer.Bolt12OfferListEvent +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent import com.vitorpamplona.quartz.utils.mapOfSet val UserMetadataForKeyKinds = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/bolt12Offers/Bolt12OffersViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/bolt12Offers/Bolt12OffersViewModel.kt index bc594dcbff..924cf8bf81 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/bolt12Offers/Bolt12OffersViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/bolt12Offers/Bolt12OffersViewModel.kt @@ -25,14 +25,14 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Bech32 +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.bolt12.Bolt12Bech32 import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch /** - * Edits the logged-in user's NIP-XX BOLT12 offer list (kind 10058). Mirrors + * Edits the logged-in user's NIP-B1 BOLT12 offer list (kind 10058). Mirrors * [com.vitorpamplona.amethyst.ui.actions.paymentTargets.PaymentTargetsViewModel]; * each entry is a canonical raw `lno1...` offer string. */ diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index e918d88d80..3e560530cc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -87,6 +87,7 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaUrlContent import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo +import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.commons.richtext.toCoilModel import com.vitorpamplona.amethyst.commons.ui.components.LoadingAnimation import com.vitorpamplona.amethyst.model.MediaAspectRatioCache @@ -144,6 +145,23 @@ private const val SHARED_VIDEO_CLEANUP_DELAY_MS = 120_000L // place; [MediaAspectRatioCache] then corrects anything unusual once the decoder reports its size. private const val DEFAULT_VIDEO_ASPECT_RATIO = 16f / 9f +/** + * Shape to assume for a [MediaUrlVideo] nobody has reported dimensions for — no imeta `dim` and + * nothing in [MediaAspectRatioCache]. + * + * Video gets [DEFAULT_VIDEO_ASPECT_RATIO]. Audio gets null, meaning "no ratio, wrap whatever the + * player asks for": audio has no picture, and the inline player sizes itself as a square (see + * `AudioPlayerSquare.audioSquare`) so the visualizer and controls get room. A square is taller than + * 16:9, so caging it in a 16:9 box — which is centre-aligned and unclipped all the way up through + * NoteComposeLayout — makes the player paint over the note header above it and the reactions row + * below. Audio also never reaches the cache, since it has no video track to report a size, so the + * miss is permanent rather than first-play-only. + */ +internal fun unknownMediaAspectRatio( + mimeType: String?, + url: String, +): Float? = if (RichTextParser.isAudioContent(mimeType, url)) null else DEFAULT_VIDEO_ASPECT_RATIO + @Composable fun ZoomableContentView( content: BaseMediaContent, @@ -203,7 +221,16 @@ fun ZoomableContentView( } is MediaUrlVideo -> { - val ratio = content.dim?.aspectRatio() ?: MediaAspectRatioCache.get(content.url) ?: DEFAULT_VIDEO_ASPECT_RATIO + // The fallback classifies the URL string, so compute it once per content — for audio + // the cache miss is permanent and this branch re-runs on every recomposition. + val fallbackRatio = + remember(content.url, content.mimeType) { + unknownMediaAspectRatio(content.mimeType, content.url) + } + val ratio = + content.dim?.aspectRatio() + ?: MediaAspectRatioCache.get(content.url) + ?: fallbackRatio val bridgedUrl = remember(content.url, useLocalBlossomBridge) { content.toCoilModel(useLocalBlossomBridge) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Bolt12ZapGallery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Bolt12ZapGallery.kt index 2720728061..64533f2f49 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Bolt12ZapGallery.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Bolt12ZapGallery.kt @@ -44,7 +44,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size25dp import com.vitorpamplona.amethyst.ui.theme.Size35Modifier import com.vitorpamplona.amethyst.ui.theme.StdStartPadding import com.vitorpamplona.amethyst.ui.theme.WidthAuthorPictureModifier -import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index e529471eda..995197944e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -351,12 +351,12 @@ import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent -import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.Podcasting20PodcastMetadata import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent 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 9efe9bc434..9c793fbd35 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 @@ -550,7 +550,7 @@ fun payViaIntent( * parameter of a BIP21/BIP321 bitcoin URI (`bitcoin:?lno=lno1…`), where the on-chain * address is optional so a Lightning-only offer stands on its own. The wallet resolves * the offer, collects the amount, and completes the payment; this is a plain intent, - * not a NIP-57/NIP-XX zap, so it produces no Nostr receipt. + * not a NIP-57/NIP-B1 zap, so it produces no Nostr receipt. */ fun payViaBolt12Intent( offer: String, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Bolt12ZapEvent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Bolt12ZapEvent.kt index 364836cc60..50ca5919e3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Bolt12ZapEvent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Bolt12ZapEvent.kt @@ -40,11 +40,11 @@ import com.vitorpamplona.amethyst.ui.note.ZapIcon import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.Size25dp import com.vitorpamplona.amethyst.ui.theme.bitcoinColor -import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent import java.text.NumberFormat /** - * Standalone card for a NIP-XX BOLT12 zap (kind 9736), styled like the NIP-57 + * Standalone card for a NIP-B1 BOLT12 zap (kind 9736), styled like the NIP-57 * lightning-zap card but labeled BOLT12. The sender is the `P` payer tag (or the * event pubkey when anonymous); the amount comes straight off the validated * `amount` tag — no LNURL provider or private-zap decryption is involved. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt index 0183867e41..530792d193 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt @@ -59,6 +59,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.dal.MusicPlaylistsFee import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.dal.MusicTracksFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.dal.NestsFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CardFeedContentState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.ChannelInvitesState import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationSummaryState import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.OpenPollsState import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal.NotificationFeedFilter @@ -138,6 +139,9 @@ class AccountFeedContentStates( val notificationsEveryone = CardFeedContentState(NotificationFeedFilter(account, TopFilter.Global), scope) val notificationsOpenPolls = OpenPollsState(account, scope) + + /** Channels somebody added the viewer to, awaiting a show-on-Messages decision. */ + val channelInvites = ChannelInvitesState(account, scope) val notificationSummary = NotificationSummaryState(account) val feedListOptions = TopNavFilterState(account, scope) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index e3502bbf9c..070d520811 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -43,6 +43,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle import com.vitorpamplona.amethyst.commons.cashu.ops.describeMintError import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvites import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.geohashChat.GeohashChatChannel @@ -75,7 +76,6 @@ import com.vitorpamplona.amethyst.model.privacyOptions.EmptyRoleBasedHttpClientB import com.vitorpamplona.amethyst.model.privacyOptions.IRoleBasedHttpClientBuilder import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder import com.vitorpamplona.amethyst.model.privateChatLastReadRoute -import com.vitorpamplona.amethyst.model.unreadPrivateChatRoute import com.vitorpamplona.amethyst.service.ClinkDebitPayer import com.vitorpamplona.amethyst.service.OnlineChecker import com.vitorpamplona.amethyst.service.V4VPaymentHandler @@ -104,6 +104,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.Marm import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.MarmotGroupIconUpload import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.MarmotGroupIconUploader import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.markRoomNoteAsRead +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.rowHasUnreadFlow import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CombinedZap import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NOTIFICATION_LAST_READ_KEY import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync.EventSync @@ -439,6 +440,15 @@ class AccountViewModel( .flowOn(Dispatchers.IO) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(30000), false) + /** + * The bottom-bar envelope dot: true when ANY Messages row is showing its blue dot. + * + * Per-row via [rowHasUnreadFlow], which mirrors what each row composable computes for itself. + * This used to call `unreadPrivateChatRoute` directly, which returns null for anything that is not + * `ChatroomKeyable` — so only NIP-17/NIP-04 DMs counted, and a public chat, ephemeral room, geohash + * cell, Marmot group, NIP-29/Buzz channel or Concord channel could sit there with a visible dot + * while the envelope stayed clean. + */ @OptIn(ExperimentalCoroutinesApi::class) val messagesHasNewItems = feedStates.dmKnown.feedContent @@ -449,14 +459,7 @@ class AccountViewModel( MutableStateFlow(null) } }.flatMapLatest { loadedFeedState -> - val flows = - loadedFeedState?.list?.mapNotNull { chat -> - unreadPrivateChatRoute(chat)?.let { (route, createdAt) -> - account.settings.getLastReadFlow(route).map { lastReadAt -> - createdAt > lastReadAt - } - } - } + val flows = loadedFeedState?.list?.mapNotNull { chat -> rowHasUnreadFlow(chat, account) } if (!flows.isNullOrEmpty()) { combine(flows) { newItems -> @@ -465,20 +468,6 @@ class AccountViewModel( } else { MutableStateFlow(false) } - }.onStart { - val feed = feedStates.dmKnown.feedContent.value - if (feed is FeedState.Loaded) { - val newItems = - feed.feed.value.list.any { chat -> - unreadPrivateChatRoute(chat)?.let { (route, createdAt) -> - val lastReadAt = - account.settings.lastReadPerRoute.value[route] - ?.value ?: 0L - createdAt > lastReadAt - } == true - } - emit(newItems) - } } val messagesHasNewItemsFlow = @@ -1218,7 +1207,7 @@ class AccountViewModel( /** * Pays a recipient's BOLT12 [offer] over the default NWC wallet using the nwc#2 * `pay` method, wrapping it as a BIP321 `bitcoin:?lno=` instruction. This is a - * plain payment, not a NIP-XX zap (no Nostr receipt); the outcome is surfaced as + * plain payment, not a NIP-B1 zap (no Nostr receipt); the outcome is surfaced as * a toast. Callers should gate on [hasNwcWallet]. */ fun payBolt12OfferViaNwc( @@ -1683,6 +1672,34 @@ class AccountViewModel( fun leaveRelayGroup(channel: RelayGroupChannel) = launchSigner { account.leaveRelayGroup(channel) } + /** + * Accept a channel somebody added me to: write it into my kind-10009 so it shows on Messages and + * follows me to other devices. No kind-9021 join — the relay already put me in the roster, which is + * why the channel opens and accepts posts today; this only records *my* decision to surface it. + */ + fun acceptChannelInvite(channel: RelayGroupChannel) = + launchSigner { + account.settings.undismissChannelInvite(channel.groupId.id) + account.follow(channel) + BuzzChannelInvites.remove(account.userProfile().pubkeyHex, channel.groupId.id) + } + + /** + * Keep the channel off Messages without touching membership. Local and reversible — I stay in the + * roster and can still open and post; [leaveChannelInvite] is the one that actually removes me. + */ + fun dismissChannelInvite(channelId: String) { + account.settings.dismissChannelInvite(channelId) + BuzzChannelInvites.remove(account.userProfile().pubkeyHex, channelId) + } + + /** Actually leave: kind-9022 to the host relay, and drop it from my list and the pending set. */ + fun leaveChannelInvite(channel: RelayGroupChannel) = + launchSigner { + account.leaveRelayGroup(channel) + BuzzChannelInvites.remove(account.userProfile().pubkeyHex, channel.groupId.id) + } + /** * Drop a Concord community from this account's private kind-13302 list. Fire-and-forget on the * signer dispatcher: the removal lands in the local cache (so the UI updates immediately) and the @@ -2174,8 +2191,6 @@ class AccountViewModel( } } - private fun unreadPrivateChatRoute(chat: Note): Pair? = unreadPrivateChatRoute(chat.event, account.signer.pubKey, account::isAllHidden) - private fun markHiddenChatroomsAsRead() { account.chatroomList.rooms.forEach { roomKey, chatroom -> if (account.isAllHidden(roomKey.users)) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmDiscovery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmDiscovery.kt index cc269e445f..6f3ea3d510 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmDiscovery.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmDiscovery.kt @@ -24,10 +24,13 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvite +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvites import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmChannels import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RELAY_GROUP_METADATA_KINDS import com.vitorpamplona.quartz.buzz.dvDmVisibility.DmVisibilityEvent @@ -37,6 +40,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllWithH import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.subscribeAsFlow import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip29RelayGroups.GroupId import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch @@ -96,10 +100,11 @@ private suspend fun runBuzzDmDiscovery( timeoutMs = 8_000, pendingOnAuthRequired = true, ) { relay, event -> - (event as? MemberAddedNotificationEvent)?.channel()?.let { BuzzDmChannels.record(me, it, relay) } + (event as? MemberAddedNotificationEvent)?.let { recordDiscovery(me, it, relay) } false } fetchDmMetadata(account, me) + classifyDiscoveredChannels(account, me) relays.forEach { relay -> launch { @@ -107,9 +112,12 @@ private suspend fun runBuzzDmDiscovery( account.client.subscribeAsFlow(relay, filter).collect { events -> var changed = false events.filterIsInstance().forEach { e -> - e.channel()?.let { if (BuzzDmChannels.record(me, it, relay)) changed = true } + if (recordDiscovery(me, e, relay)) changed = true + } + if (changed) { + fetchDmMetadata(account, me) + classifyDiscoveredChannels(account, me) } - if (changed) fetchDmMetadata(account, me) } } } @@ -129,3 +137,59 @@ private suspend fun fetchDmMetadata( if (byRelay.isEmpty()) return account.client.fetchAllWithHooks(filters = byRelay, timeoutMs = 8_000, pendingOnAuthRequired = true) { _, _ -> false } } + +/** + * Records a kind-44100 "you were added" into [BuzzDmChannels] so its directory can be fetched, and — when + * somebody *else* did the adding — into [BuzzChannelInvites] as well. + * + * The relay emits this same kind for a self-join with `actor == me`, so the actor is what separates "I + * joined this" from "a stranger put me in this". Everything is provisionally treated as a DM here because + * the channel's type only becomes knowable once its kind-39000 lands; [classifyDiscoveredChannels] sorts + * them out immediately afterwards. + */ +private fun recordDiscovery( + me: HexKey, + event: MemberAddedNotificationEvent, + relay: NormalizedRelayUrl, +): Boolean { + val channelId = event.channel() ?: return false + val changed = BuzzDmChannels.record(me, channelId, relay) + val actor = event.actor() + if (actor == null || !actor.equals(me, ignoreCase = true)) { + BuzzChannelInvites.record(me, BuzzChannelInvite(channelId, relay, actor, event.createdAt)) + } + return changed +} + +/** + * Splits what discovery found into DMs and named channels, now that each channel's kind-39000 has loaded. + * + * A `t = dm` channel is a real DM: it stays in [BuzzDmChannels], whose always-on tail keeps it warm so it + * can reach Notifications without being opened, and it is never an "invite". Anything else is a named + * channel somebody added the viewer to; it must NOT be silently subscribed, so it is dropped from + * [BuzzDmChannels] and left in [BuzzChannelInvites] for the viewer to accept or dismiss. + * + * Channels already in the viewer's kind-10009 (accepted earlier, or joined from this device) are not + * invites — the ordinary joined-group path owns them. + */ +private fun classifyDiscoveredChannels( + account: Account, + me: HexKey, +) { + val joined = + account.relayGroupList.liveRelayGroupList.value + .mapNotNullTo(mutableSetOf()) { it.groupId } + + BuzzDmChannels.channelsFor(me).forEach { (channelId, relay) -> + val metadata = LocalCache.getRelayGroupChannelIfExists(GroupId(channelId, relay))?.event + when { + // Type not known yet — leave both entries alone and re-run when the directory lands. + metadata == null -> Unit + metadata.isBuzzDmChannel() -> BuzzChannelInvites.remove(me, channelId) + else -> { + BuzzDmChannels.remove(me, channelId) + if (channelId in joined) BuzzChannelInvites.remove(me, channelId) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/MinichatReply.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/MinichatReply.kt index 2434a67e12..c6b8d8d4f9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/MinichatReply.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/MinichatReply.kt @@ -24,15 +24,33 @@ import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nipC7Chats.ChatEvent /** * Whether [event] is a **minichat thread reply** — a reply that lives inside the thread opened from - * its parent message, NOT as a flat sibling in the main timeline. Two dialects express the same idea: + * its parent message, NOT as a flat sibling in the main timeline. Three dialects express the same idea: * * - **NIP-28/NIP-29 (public chats, Concord)**: a kind-1111 [CommentEvent]. - * - **Buzz workspaces**: a kind-40002 [StreamMessageV2Event] carrying a NIP-10 `reply`-marked `e` tag - * and NOT flagged `broadcast` (Buzz rejects kind-1111, so it threads chat with 40002 markers; a - * `broadcast=1` reply is an inline timeline sibling, matching block/buzz's `isThreadReply`). + * - **Buzz workspaces, current**: a kind-9 [ChatEvent] carrying a NIP-10 `reply`-marked `e` tag. This + * is what every live Buzz client writes — `_buildReplyTags` in its Flutter client emits + * `["e", id, "", "reply"]` for a direct reply and `["e", root, "", "root"]` + + * `["e", parent, "", "reply"]` for a nested one, and all three of their clients send chat as kind 9. + * - **Buzz workspaces, legacy**: a kind-40002 [StreamMessageV2Event] with the same markers and NOT + * flagged `broadcast`. Nothing in Buzz writes 40002 any more (their own NOSTR.md grades it + * "Buzz-only — no standard NIP-29 client renders these"), but events exist in the wild from the + * 10002 -> 40001 -> 40002 migration, and Amethyst itself wrote some, so it stays readable. + * + * ### Why a marked `e` and not `q` + * + * NIP-C7 gives kind 9 exactly one reply mechanism — `["q", , , ]` — and never + * mentions `e` at all. So a marked `e` carries no C7 meaning and is free to denote a *thread* reply, + * which is precisely how Buzz uses it. The marker is what separates the cases: WhiteNoise/Marmot + * thread kind-9 chat with a **plain, unmarked** `e`, which is an in-chat reply and must keep rendering + * as a quote bubble in the timeline — so matching on the `reply` marker (never on the bare tag) leaves + * that dialect untouched. + * + * A `broadcast=1` reply is an inline timeline sibling ("also send to channel"), matching block/buzz's + * `isThreadReply`. Kind 9 has no broadcast tag, so a marked kind-9 is always thread-only. * * The timeline filter drops these (they belong in the minichat), the minichat count counts them, and * the minichat feed shows them — so all three agree on one definition. @@ -40,6 +58,7 @@ import com.vitorpamplona.quartz.nip22Comments.CommentEvent fun isMinichatReply(event: Event?): Boolean = when (event) { is CommentEvent -> true + is ChatEvent -> event.tags.buzzThreadReply() != null is StreamMessageV2Event -> !event.isBroadcast() && event.tags.buzzThreadReply() != null else -> false } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt index fe5f68d670..adf62d63be 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt @@ -149,17 +149,17 @@ fun ChatroomMessageCompose( RenderChannelAdminSystemMessage(baseNote, accountViewModel, nav) } else if (event is SystemMessageEvent) { // Buzz kind-40099: relay-signed room narration (join/leave/topic). - RenderBuzzSystemMessage(baseNote) + RenderBuzzSystemMessage(baseNote, accountViewModel, nav) } else if (event is StreamMessageDiffEvent) { // Buzz kind-40008: a code/text diff pushed into the channel. RenderBuzzDiff(baseNote) } else if (event is ForumVoteEvent) { // Buzz kind-45002: a forum up/down vote. - RenderBuzzForumVote(baseNote) + RenderBuzzForumVote(baseNote, accountViewModel) } else if (isBuzzActivityRow(event)) { // Buzz agent-job (43xxx) and huddle (48xxx) lifecycle narration. Huddles // especially must be caught here — their content is JSON, not chat text. - RenderBuzzActivityRow(baseNote) + RenderBuzzActivityRow(baseNote, accountViewModel) } else { NormalChatNote( baseNote, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatSystemMessage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatSystemMessage.kt index 4fb1f50ffa..598827b42f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatSystemMessage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatSystemMessage.kt @@ -20,22 +20,29 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.Font12SP +import com.vitorpamplona.amethyst.ui.theme.Size18dp import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.ui.theme.grayText @@ -43,11 +50,16 @@ import com.vitorpamplona.amethyst.ui.theme.grayText * A centered, muted system line for events that narrate the room rather than talk * in it (channel created, profile updated, ...). Visually distinct from user * bubbles: no author row, no tail, one small pill in the middle of the feed. + * + * [leading] is an optional slot rendered inside the pill, before the text — used to + * put the avatar of whoever the line is about ("Alice joined") next to the sentence, + * so a membership change is recognizable without reading the name. */ @Composable fun ChatSystemMessage( text: String, onClick: (() -> Unit)? = null, + leading: (@Composable () -> Unit)? = null, ) { Row( modifier = @@ -66,19 +78,33 @@ fun ChatSystemMessage( Modifier }, ) { - SystemMessageText(text) + if (leading == null) { + SystemMessageText(text) + } else { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.padding(start = 8.dp), + ) { + leading() + SystemMessageText(text, startPadding = 0.dp) + } + } } } } @Composable -private fun SystemMessageText(text: String) { +private fun SystemMessageText( + text: String, + startPadding: Dp = 12.dp, +) { Text( text = text, fontSize = Font12SP, color = MaterialTheme.colorScheme.grayText, textAlign = TextAlign.Center, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 5.dp), + modifier = Modifier.padding(start = startPadding, end = 12.dp, top = 5.dp, bottom = 5.dp), ) } @@ -88,5 +114,9 @@ private fun ChatSystemMessagePreview() { ThemeComparisonColumn { ChatSystemMessage("Alice created the channel Amethyst Users", onClick = {}) ChatSystemMessage("Alice updated the channel profile") + ChatSystemMessage( + "Bob was added by Alice", + leading = { Box(Modifier.size(Size18dp).clip(CircleShape).background(MaterialTheme.colorScheme.primary)) }, + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderBuzzNotes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderBuzzNotes.kt index b1ee7fa174..fcf78442df 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderBuzzNotes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderBuzzNotes.kt @@ -110,33 +110,6 @@ fun RenderBuzzEditedNote( } } -/** - * A Buzz kind-40099 system message ("X joined", "channel created", "topic changed"): - * narrates the room rather than speaking in it, so it renders as a centered system - * line like the NIP-28 admin events, from the relay-signed JSON payload. - */ -@Composable -fun RenderBuzzSystemMessage(note: Note) { - val event = note.event as? SystemMessageEvent ?: return - val text = remember(event) { buzzSystemMessageText(event) } - ChatSystemMessage(text = text) -} - -/** - * The one-line label for a Buzz kind-40099 system message. Relay-emitted machine text - * (join/leave/topic); shown as-is rather than through string resources — the payload - * vocabulary is Buzz's, not ours to translate yet. Pure so the Messages-list preview and - * the in-chat system line render identical text. - */ -fun buzzSystemMessageText(event: SystemMessageEvent): String { - val payload = event.payload() - return when (payload?.type) { - "topic_changed" -> payload.topic?.let { "topic: $it" } ?: "topic changed" - "purpose_changed" -> payload.purpose?.let { "purpose: $it" } ?: "purpose changed" - else -> payload?.type?.replace('_', ' ') - } ?: event.content.take(120) -} - /** * True for the Buzz agent-job and huddle lifecycle kinds that [RenderBuzzActivityRow] * narrates as a centered system line rather than a chat bubble. Huddle events in @@ -161,32 +134,59 @@ fun isBuzzActivityRow(event: Event?): Boolean = * snippet (the human-readable status/result/error the agent wrote). */ @Composable -fun RenderBuzzActivityRow(note: Note) { +fun RenderBuzzActivityRow( + note: Note, + accountViewModel: AccountViewModel, +) { val event = note.event ?: return - val text = remember(event) { buzzActivityLabel(event) ?: event.content.take(120) } + val text = buzzActivityLabel(event, accountViewModel) ?: remember(event) { event.content.take(120) } ChatSystemMessage(text = text) } /** * The centered-system-line label for a Buzz agent-job or huddle lifecycle event, or null if - * [event] isn't one. Pure so the Messages-list preview and the in-chat activity row read the - * same. The label is derived from the kind; job progress/result/error also append a short - * content snippet (the human-readable status/result/error the agent wrote). + * [event] isn't one. Shared by the Messages-list preview and the in-chat activity row so the + * two read the same. + * + * Huddle joins/leaves name the participant from the event's `p` tag (falling back to the + * signer), and job lines name the signer — the agent that accepted, the human that requested — + * because "someone joined the huddle" in a busy channel says nothing about who to talk to. + * Job progress/result/error also append a short snippet of what the agent wrote. */ -fun buzzActivityLabel(event: Event): String? = - when (event) { - is JobRequestEvent -> "⚙ job requested" + event.request().snippet() - is JobAcceptedEvent -> "⚙ job accepted" - is JobProgressEvent -> "⚙ job progress" + (event.status()?.let { ": $it" } ?: "") + event.content.snippet() - is JobResultEvent -> "⚙ job result" + event.result().snippet() - is JobCancelEvent -> "⚙ job cancelled" - is JobErrorEvent -> "⚠ job error" + event.error().snippet() - is HuddleStartedEvent -> "🔊 huddle started" - is HuddleParticipantJoinedEvent -> "🔊 someone joined the huddle" - is HuddleParticipantLeftEvent -> "🔊 someone left the huddle" - is HuddleEndedEvent -> "🔊 huddle ended" +@Composable +fun buzzActivityLabel( + event: Event, + accountViewModel: AccountViewModel, +): String? { + val signer = observeUserNameByHex(event.pubKey, accountViewModel) + val participant = + observeUserNameByHex( + remember(event) { + when (event) { + is HuddleParticipantJoinedEvent -> event.participant() ?: event.pubKey + is HuddleParticipantLeftEvent -> event.participant() ?: event.pubKey + else -> null + } + }, + accountViewModel, + ) + + return when (event) { + is JobRequestEvent -> stringRes(R.string.buzz_job_requested, signer) + remember(event) { event.request().snippet() } + is JobAcceptedEvent -> stringRes(R.string.buzz_job_accepted, signer) + is JobProgressEvent -> + stringRes(R.string.buzz_job_progress) + + remember(event) { (event.status()?.let { ": $it" } ?: "") + event.content.snippet() } + is JobResultEvent -> stringRes(R.string.buzz_job_result) + remember(event) { event.result().snippet() } + is JobCancelEvent -> stringRes(R.string.buzz_job_cancelled) + is JobErrorEvent -> stringRes(R.string.buzz_job_error) + remember(event) { event.error().snippet() } + is HuddleStartedEvent -> stringRes(R.string.buzz_huddle_started, signer) + is HuddleParticipantJoinedEvent -> stringRes(R.string.buzz_huddle_joined, participant) + is HuddleParticipantLeftEvent -> stringRes(R.string.buzz_huddle_left, participant) + is HuddleEndedEvent -> stringRes(R.string.buzz_huddle_ended) else -> null } +} /** * A human-readable one-line preview of a Buzz chat-timeline event for the Messages list, or null @@ -195,12 +195,16 @@ fun buzzActivityLabel(event: Event): String? = * so this returns the same summary the in-chat system/activity/diff row shows instead of dumping * raw payload into the preview. Keyed off the exact set in [isBuzzChatTimelineContent]. */ -fun buzzTimelinePreviewSummary(event: Event): String? = +@Composable +fun buzzTimelinePreviewSummary( + event: Event, + accountViewModel: AccountViewModel, +): String? = when (event) { is StreamMessageV2Event -> null - is SystemMessageEvent -> buzzSystemMessageText(event) - is StreamMessageDiffEvent -> event.diffMeta()?.filePath?.let { "📄 $it" } ?: "📄 diff" - else -> buzzActivityLabel(event) + is SystemMessageEvent -> buzzSystemMessageText(event, accountViewModel) + is StreamMessageDiffEvent -> remember(event) { event.diffMeta()?.filePath?.let { "📄 $it" } ?: "📄 diff" } + else -> buzzActivityLabel(event, accountViewModel) } /** A short one-line snippet of free-text content appended after a label, or "" if blank. */ @@ -265,9 +269,21 @@ fun RenderBuzzDiff(note: Note) { /** A Buzz kind-45002 forum vote: a lightweight up/down signal, shown as a system line. */ @Composable -fun RenderBuzzForumVote(note: Note) { +fun RenderBuzzForumVote( + note: Note, + accountViewModel: AccountViewModel, +) { val event = note.event as? ForumVoteEvent ?: return // Content is the vote token ("+"/"-" or similar); show a compact glyph line. - val text = remember(event) { if (event.content.trim().startsWith("-")) "▼ downvoted a post" else "▲ upvoted a post" } - ChatSystemMessage(text = text) + val isDownVote = remember(event) { event.content.trim().startsWith("-") } + val voter = observeUserNameByHex(event.pubKey, accountViewModel) + + ChatSystemMessage( + text = + if (isDownVote) { + stringRes(R.string.buzz_forum_downvoted, voter) + } else { + stringRes(R.string.buzz_forum_upvoted, voter) + }, + ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderBuzzSystemMessage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderBuzzSystemMessage.kt new file mode 100644 index 0000000000..c6d237a914 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderBuzzSystemMessage.kt @@ -0,0 +1,207 @@ +/* + * 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.chats.feed.types + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.res.pluralStringResource +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.note.UserPicture +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.ChatSystemMessage +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size18dp +import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent +import com.vitorpamplona.quartz.buzz.stream.SystemMessagePayload +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * A Buzz kind-40099 system message: relay-authored narration of a channel state change + * ("Alice joined", "Bob made this channel private"). It narrates the room rather than + * speaking in it, so it renders as a centered system line like the NIP-28 admin events — + * with the avatar of whoever the line is about, tapping through to their profile. + * + * The event's own author is the **relay keypair**, never a person, so the people in the + * sentence come from the signed JSON payload's `actor`/`target` pubkeys, not from + * `note.author`. + */ +@Composable +fun RenderBuzzSystemMessage( + note: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val event = note.event as? SystemMessageEvent ?: return + val payload = remember(event) { event.payload() } + val subject = remember(payload) { payload?.subject() } + + ChatSystemMessage( + text = buzzSystemMessageText(event, accountViewModel), + onClick = subject?.let { { nav.nav(Route.Profile(it)) } }, + leading = + subject?.let { + { + UserPicture( + userHex = it, + size = Size18dp, + accountViewModel = accountViewModel, + nav = nav, + ) + } + }, + ) +} + +/** + * The one-line sentence for a Buzz kind-40099 system message, with `actor`/`target` pubkeys + * resolved to the names the viewer knows them by (petname first, like everywhere else). + * + * Shared by the in-chat system line and the Messages-list preview so the two can never word + * the same event differently. A payload this version has no sentence for degrades to + * "name: the_raw_type" rather than vanishing — new relay vocabulary stays legible. + */ +@Composable +fun buzzSystemMessageText( + event: SystemMessageEvent, + accountViewModel: AccountViewModel, +): String { + val payload = remember(event) { event.payload() } ?: return remember(event) { event.content.take(120) } + + val actor = observeUserNameByHex(payload.actor, accountViewModel) + val target = observeUserNameByHex(payload.target, accountViewModel) + val subject = if (payload.target != null) target else actor + + return when (payload.type) { + // The relay emits the same type for "I joined" and "someone added me", separated only by + // actor == target. Wording them the same would credit a self-join to whoever invited. + SystemMessagePayload.MEMBER_JOINED -> + if (payload.target == null || payload.target == payload.actor) { + stringRes(R.string.buzz_system_member_joined, subject) + } else { + stringRes(R.string.buzz_system_member_added, target, actor) + } + + SystemMessagePayload.MEMBER_LEFT -> stringRes(R.string.buzz_system_member_left, subject) + + SystemMessagePayload.MEMBER_REMOVED -> + if (payload.target == null || payload.target == payload.actor) { + stringRes(R.string.buzz_system_member_left, subject) + } else { + stringRes(R.string.buzz_system_member_removed, target, actor) + } + + SystemMessagePayload.TOPIC_CHANGED -> + payload.topic?.takeIf { it.isNotBlank() }?.let { + stringRes(R.string.buzz_system_topic_changed, actor, it) + } ?: stringRes(R.string.buzz_system_topic_cleared, actor) + + SystemMessagePayload.PURPOSE_CHANGED -> + payload.purpose?.takeIf { it.isNotBlank() }?.let { + stringRes(R.string.buzz_system_purpose_changed, actor, it) + } ?: stringRes(R.string.buzz_system_purpose_cleared, actor) + + // Buzz has exactly two visibility modes; spell out what each one means for the reader + // rather than echoing the relay's token, and keep a literal fallback for a third. + SystemMessagePayload.VISIBILITY_CHANGED -> + when (payload.visibility) { + SystemMessagePayload.VISIBILITY_OPEN -> stringRes(R.string.buzz_system_visibility_open, actor) + SystemMessagePayload.VISIBILITY_PRIVATE -> stringRes(R.string.buzz_system_visibility_private, actor) + else -> stringRes(R.string.buzz_system_visibility_other, actor, payload.visibility ?: "") + } + + // A null ttl_seconds is the relay clearing the TTL (messages become permanent). + SystemMessagePayload.TTL_CHANGED -> + payload.ttlSeconds?.takeIf { it > 0 }?.let { + stringRes(R.string.buzz_system_ttl_set, actor, ttlDurationText(it)) + } ?: stringRes(R.string.buzz_system_ttl_cleared, actor) + + SystemMessagePayload.CHANNEL_ARCHIVED -> stringRes(R.string.buzz_system_channel_archived, actor) + SystemMessagePayload.CHANNEL_UNARCHIVED -> stringRes(R.string.buzz_system_channel_unarchived, actor) + SystemMessagePayload.CHANNEL_CREATED -> stringRes(R.string.buzz_system_channel_created, actor) + SystemMessagePayload.CHANNEL_DELETED -> stringRes(R.string.buzz_system_channel_deleted, actor) + + SystemMessagePayload.MESSAGE_DELETED -> + payload.publicReason?.takeIf { it.isNotBlank() }?.let { + stringRes(R.string.buzz_system_message_deleted_reason, actor, it) + } ?: stringRes(R.string.buzz_system_message_deleted, actor) + + SystemMessagePayload.DM_CREATED -> stringRes(R.string.buzz_system_dm_created, actor) + + else -> stringRes(R.string.buzz_system_unknown, actor, payload.type.replace('_', ' ')) + } +} + +/** + * A TTL as a rounded, pluralized duration ("7 days", "12 hours"), reusing the same duration + * plurals as the last-seen line. Rounds to the largest whole unit that fits, which is what the + * relay's own values are (a day, a week) and reads better than "604800 seconds". + */ +@Composable +private fun ttlDurationText(seconds: Long): String = + when { + seconds >= TimeUtils.ONE_DAY -> { + val n = (seconds / TimeUtils.ONE_DAY).toInt() + pluralStringResource(R.plurals.duration_days, n, n) + } + seconds >= TimeUtils.ONE_HOUR -> { + val n = (seconds / TimeUtils.ONE_HOUR).toInt() + pluralStringResource(R.plurals.duration_hours, n, n) + } + else -> { + val n = (seconds / TimeUtils.ONE_MINUTE).toInt().coerceAtLeast(1) + pluralStringResource(R.plurals.duration_minutes, n, n) + } + } + +/** + * The display name for a pubkey that arrived inside an event's *payload* rather than as its + * author — resolving it needs a [com.vitorpamplona.amethyst.commons.model.User] first, which may + * not be in the cache yet. + * + * Returns the shortened hex until the profile loads, and empty string for a null pubkey so a + * caller can format a sentence whose subject the relay omitted without printing "null". + */ +@Composable +fun observeUserNameByHex( + pubkey: HexKey?, + accountViewModel: AccountViewModel, +): String { + if (pubkey == null) return "" + + var user by remember(pubkey) { mutableStateOf(accountViewModel.getUserIfExists(pubkey)) } + + if (user == null) { + LaunchedEffect(pubkey) { user = accountViewModel.checkGetOrCreateUser(pubkey) } + } + + val loaded = user ?: return remember(pubkey) { pubkey.take(8) } + val name by observeUserName(loaded, accountViewModel) + return name +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterGoalForLiveActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterGoalForLiveActivity.kt index 16ce66b533..6a8f7d9ac7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterGoalForLiveActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterGoalForLiveActivity.kt @@ -26,8 +26,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent -import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent /** * Fetches the NIP-75 zap goal referenced by a live stream plus the zap receipts diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt index 12a8396a9a..c380bab385 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt @@ -28,8 +28,8 @@ import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessa import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent import com.vitorpamplona.quartz.nip53LiveActivities.raid.LiveActivitiesRaidEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent -import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent fun filterMessagesToLiveActivities( channel: LiveActivitiesChannel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupUnread.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupUnread.kt index 75659f454e..a26ea3dc1f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupUnread.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupUnread.kt @@ -23,6 +23,9 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relay import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.isMinichatReply import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip29RelayGroups.GroupId @@ -78,11 +81,41 @@ fun relayGroupServerHasUnreadFlow( } }.distinctUntilChanged() -/** Whether this group's message store holds any acceptable chat content created after [sinceSecs]. */ +/** + * Whether [note] is one of this group's **timeline** messages — what the channel feed renders, what + * the Messages row previews, and what the unread dot counts. + * + * Two exclusions, and both matter for the same reason: the row summary must not disagree with what + * opening the channel shows. + * - Non-content (`reaction`/deletion/label) carries the group's `h` tag too, so [isGroupChatContent] + * gates it out — a trailing 👍 must not become the "last message". + * - A **minichat thread reply** lives in the thread opened from its parent, not in the timeline + * ([isMinichatReply], the same predicate `ChannelFeedFilter` uses). Without this the Messages row + * previews a reply the channel never displays, and the unread dot lights for activity that leaves + * the timeline unchanged — you open the group, see nothing new, and the dot clears. + * + * The Concord side solves this identically with `isConcordTimelineMessage`. + */ +fun isRelayGroupTimelineMessage( + note: Note, + account: Account, +): Boolean = note.event?.isGroupChatContent() == true && !isMinichatReply(note.event) && account.isAcceptable(note) + +/** + * The newest timeline message in this group (see [isRelayGroupTimelineMessage]), or null if none — + * the note the Messages row shows as the group's "last message". + */ +fun RelayGroupChannel.newestTimelineNote(account: Account): Note? = + notes + .filter { _, note -> isRelayGroupTimelineMessage(note, account) } + .sortedByDefaultFeedOrder() + .firstOrNull() + +/** Whether this group's message store holds any acceptable timeline message created after [sinceSecs]. */ private fun RelayGroupChannel.hasChatNewerThan( account: Account, sinceSecs: Long, ): Boolean = notes.count { _, note -> - (note.createdAt() ?: 0L) > sinceSecs && account.isAcceptable(note) && note.event?.isGroupChatContent() == true + (note.createdAt() ?: 0L) > sinceSecs && isRelayGroupTimelineMessage(note, account) } > 0 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/BuzzDmJoinedChatTailFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/BuzzDmJoinedChatTailFilterAssembler.kt index 4547036f84..0ebe1be439 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/BuzzDmJoinedChatTailFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/BuzzDmJoinedChatTailFilterAssembler.kt @@ -28,18 +28,20 @@ import com.vitorpamplona.amethyst.commons.relayClient.paging.WindowLoadTracker import com.vitorpamplona.amethyst.commons.relayClient.paging.trackingListener import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.filterGroupNotificationsToPubkey import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.GroupTag +import com.vitorpamplona.quartz.nip29RelayGroups.GroupId import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.flow.StateFlow -/** One screen's request to keep the viewer's Buzz DM channels' recent chat live. */ +/** One Buzz DM channel whose recent chat is kept live. */ class BuzzDmJoinedChatTailQueryState( val account: Account, + val groupId: GroupId, ) /** @@ -71,7 +73,7 @@ class BuzzDmJoinedChatTailFilterAssembler( class BuzzDmJoinedChatTailSubAssembler( client: INostrClient, allKeys: () -> Set, -) : PerUniqueIdEoseManager(client, allKeys) { +) : PerUniqueIdEoseManager(client, allKeys) { private val windowLoad = WindowLoadTracker("buzzDm.preview.live") val loadingMore: StateFlow = windowLoad.loading @@ -79,23 +81,35 @@ class BuzzDmJoinedChatTailSubAssembler( key: BuzzDmJoinedChatTailQueryState, since: SincePerRelayMap?, ): List? { + // The preload only mounts visible channels; re-checked here so hiding a conversation + // empties its subscription even if the key outlives the recomposition. val me = key.account.userProfile().pubkeyHex - val hidden = BuzzDmRegistry.hiddenFor(me) - val channels = BuzzDmChannels.channelsFor(me).filterKeys { it !in hidden } - if (channels.isEmpty()) { - windowLoad.setExpectedRelays(emptySet()) - return null - } + if (key.groupId.id in BuzzDmRegistry.hiddenFor(me)) return null - // Reuse the joined-group tail builder: one #h filter per host relay carrying every DM channel id - // on it, bounded by the shared recent floor (no per-channel limit, reconnect-safe). - val asTags = channels.map { (channelId, relay) -> GroupTag(channelId, relay.url) } - val filters = buildRelayGroupJoinedChatTailFilters(asTags, DmHistoryTuning.recentBoundary()) - windowLoad.setExpectedRelays(filters.mapTo(mutableSetOf()) { it.relay }) - return filters + // Same one-subscription-per-channel shape as the joined-group tail: a Buzz DM is a + // relay-authoritative NIP-29 group, so batching ids into one `#h` would register the + // subscription as global on buzz and never deliver live messages. See + // [buildRelayGroupJoinedChatTailFilter]. The second filter carries the DM's activity + // addressed to me (reactions/zaps/reports) — same channel, so the subscription stays scoped. + val relay = key.groupId.relayUrl + windowLoad.setExpectedRelays(setOf(relay)) + return listOf( + buildRelayGroupJoinedChatTailFilter(key.groupId, DmHistoryTuning.recentBoundary()), + // Newest message at any age — a DM conversation dormant for over a week must still show its + // last message on Messages, not a placeholder. See [buildRelayGroupPreviewFilter]. + buildRelayGroupPreviewFilter(key.groupId, since?.get(relay)?.time), + // A DM channel takes reactions/deletions the same way a group channel does. + buildRelayGroupAuxFilter(key.groupId, DmHistoryTuning.recentBoundary()), + ) + + filterGroupNotificationsToPubkey( + relay = relay, + pubkey = me, + groupIds = listOf(key.groupId.id), + since = since?.get(relay)?.time, + ) } - override fun id(key: BuzzDmJoinedChatTailQueryState) = key.account + override fun id(key: BuzzDmJoinedChatTailQueryState) = key.groupId override fun newSub(key: BuzzDmJoinedChatTailQueryState): Subscription { windowLoad.startLoading(key.account.scope) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupChatSubscriptions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupChatSubscriptions.kt index 39784de493..4b1f5d3b22 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupChatSubscriptions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupChatSubscriptions.kt @@ -23,13 +23,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relay import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.remember import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmChannels import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmRegistry +import com.vitorpamplona.amethyst.commons.model.chats.ChatFeedType import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip29RelayGroups.GroupId /** @@ -52,18 +55,35 @@ fun RelayGroupJoinedStatePreload(accountViewModel: AccountViewModel) { /** * Always-on preview **live tail** for joined groups' recent chat, mounted alongside [RelayGroupJoinedStatePreload] - * — keeps the Messages-list previews reflecting the true newest message app-wide. Re-derives on join/leave. + * — keeps the Messages-list previews reflecting the true newest message app-wide. + * + * Mounts **one subscription per joined group** (not one per host relay): a subscription whose `#h` carries + * more than one group id is registered as a *global* subscription by `block/buzz` and then never receives + * channel-scoped events, so a batched tail backfills once and goes deaf. See + * [buildRelayGroupJoinedChatTailFilter]. Joining/leaving adds or removes a key, so nothing needs an + * explicit invalidate; a group whose relay url won't normalize is skipped, exactly as the Messages feed + * filter skips it. */ @Composable fun RelayGroupJoinedChatTailPreload(accountViewModel: AccountViewModel) { val account = accountViewModel.account val dataSource = accountViewModel.dataSources().relayGroupJoinedChatTail - val state = remember(account) { RelayGroupJoinedChatTailQueryState(account) } val joined by account.relayGroupList.liveRelayGroupList.collectAsStateWithLifecycle() - LaunchedEffect(joined) { dataSource.invalidateFilters() } + val enabledFeeds by account.settings.enabledChatFeeds.collectAsStateWithLifecycle() - KeyDataSourceSubscription(state, dataSource) + if (ChatFeedType.NIP29 in enabledFeeds) { + joined.forEach { tag -> + val relay = RelayUrlNormalizer.normalizeOrNull(tag.relayUrl) + if (relay != null) { + val groupId = GroupId(tag.groupId, relay) + key(groupId) { + val state = remember(account, groupId) { RelayGroupJoinedChatTailQueryState(account, groupId) } + KeyDataSourceSubscription(state, dataSource) + } + } + } + } } /** @@ -77,13 +97,27 @@ fun RelayGroupJoinedChatTailPreload(accountViewModel: AccountViewModel) { fun BuzzDmJoinedChatTailPreload(accountViewModel: AccountViewModel) { val account = accountViewModel.account val dataSource = accountViewModel.dataSources().buzzDmJoinedChatTail - val state = remember(account) { BuzzDmJoinedChatTailQueryState(account) } + val me = account.userProfile().pubkeyHex + // Both values must be read (not just collected) so the snapshot system recomposes this + // preload — and re-derives the mounted set — whenever a DM is discovered or hidden. val channels by BuzzDmChannels.flow.collectAsStateWithLifecycle() val hidden by BuzzDmRegistry.hidden.collectAsStateWithLifecycle() - LaunchedEffect(channels, hidden) { dataSource.invalidateFilters() } - KeyDataSourceSubscription(state, dataSource) + val visible = + remember(channels, hidden, me) { + BuzzDmChannels.channelsFor(me).filterKeys { it !in BuzzDmRegistry.hiddenFor(me) } + } + + // One subscription per DM channel — batching their ids into a single `#h` makes buzz treat the + // subscription as global and stop delivering live messages. See [buildRelayGroupJoinedChatTailFilter]. + visible.forEach { (channelId, relay) -> + val groupId = GroupId(channelId, relay) + key(groupId) { + val state = remember(account, groupId) { BuzzDmJoinedChatTailQueryState(account, groupId) } + KeyDataSourceSubscription(state, dataSource) + } + } } /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuilders.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuilders.kt index 6e3e585b5c..4fb3c170eb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuilders.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuilders.kt @@ -54,13 +54,16 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip29RelayGroups.GroupId import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.SupportedRolesEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.DeleteEventEvent import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.GroupTag import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent @@ -172,6 +175,33 @@ val BUZZ_RELAY_GROUP_TIMELINE_EXTRA_KINDS = /** The timeline kinds requested for every relay-group REQ (NIP-29 + Buzz; see above). */ val RELAY_GROUP_ALL_TIMELINE_KINDS = RELAY_GROUP_TIMELINE_KINDS + BUZZ_RELAY_GROUP_TIMELINE_EXTRA_KINDS +/** + * Channel **aux** kinds — overlays that modify an existing row rather than being a row: reactions (7), + * NIP-09 deletions (5) and the Buzz-native NIP-29 delete (9005). + * + * Requested `#h`-scoped on the channel subscription, which is how the Buzz reference client does it + * (`channelEventKinds` in its Flutter client bundles deletion + reaction + the message kinds into the + * one `#h` channel REQ). Amethyst otherwise only learns about reactions through the shared `#e` + * EventFinder query keyed on the ids of notes currently on screen — and that query carries no `#h`, so + * on a relay that scopes live delivery per channel it is registered as a *global* subscription and can + * never receive them live (a reaction inherits its target's channel, so it IS channel-scoped). The + * result was reaction chips that only ever appeared on a re-query, never as they happened. + * + * Kept OUT of [RELAY_GROUP_ALL_TIMELINE_KINDS] on purpose: that set feeds the backward history pager, + * whose `until` cursor walks `created_at`. Letting reactions consume a page's `limit` would advance the + * cursor past chat messages that were never delivered — the same class of cursor-skip bug that the + * unconditional-Buzz-kinds note above records. + */ +val RELAY_GROUP_AUX_KINDS = + listOf( + DeletionEvent.KIND, + ReactionEvent.KIND, + DeleteEventEvent.KIND, + ) + +/** How many aux events a channel replays on subscribe. Bounds the backfill; live delivery is unbounded. */ +const val RELAY_GROUP_AUX_LIMIT = 500 + /** * Kinds requested on the **open channel's live tail only** — the timeline set plus the * ephemeral kind-20002 typing indicator. Typing is scoped to the one channel on screen @@ -233,25 +263,100 @@ fun buildRelayGroupStateFilters( } /** - * Recent chat of every joined group, **one `#h` filter per host relay** carrying that relay's group ids, - * bounded by a shared time floor ([sinceEpoch]) and **no per-group `limit`** — this is what lets the whole - * relay's groups batch into a single REQ and makes it reconnect-safe. + * Recent chat of **one** joined group: a single-valued `#h` filter on its host relay, bounded by a shared + * time floor ([sinceEpoch]) and **no `limit`** — a time floor bounds it, so it stays reconnect-safe (a + * reconnect re-issues one `since=window` REQ, never a page replay). + * + * ### Why one group per filter — and per *subscription* + * + * This used to batch every joined group on a relay into ONE filter carrying every group id in `#h`. That + * shape is valid NIP-01 (a multi-value tag filter is an OR), and relays serve it correctly for **stored** + * queries — which is exactly what made the bug so confusing: the boot backfill populated every group, so + * the Messages list looked right until it needed to update. + * + * But `block/buzz` (the relay behind `*.communities.buzz.xyz`) indexes each live subscription under a + * *single* channel uuid, resolved from the filters' `#h`. When two or more distinct ids appear anywhere + * across a subscription's filters, `extract_channel_id_from_filters` returns `None` and the subscription + * is registered as **global** — and global subscriptions deliberately never receive channel-scoped events + * (`fan_out_scoped`: "Global subscriptions do NOT receive channel-scoped events", guarding against leaking + * private channel content to a subscriber whose membership wasn't checked per channel). Net effect: a + * batched tail gets full history at EOSE and then goes permanently deaf, so the Messages row froze while + * the open chat — which always used a single-valued `#h` — stayed live. + * + * The resolution scans **all filters of a subscription**, so splitting into one filter per group is not + * enough: each group needs its **own subscription** (see [RelayGroupJoinedChatTailFilterAssembler], which + * keys its EOSE manager on [GroupId]). Relays advertise room for this — buzz allows `max_subscriptions: + * 1024` against `max_filters: 10` — and it sidesteps the filter cap for anyone in more than ten groups. */ -fun buildRelayGroupJoinedChatTailFilters( - joined: Collection, +fun buildRelayGroupJoinedChatTailFilter( + groupId: GroupId, sinceEpoch: Long, -): List = - byHostRelay(joined).map { (relay, ids) -> - RelayBasedFilter( - relay = relay, - filter = - Filter( - kinds = RELAY_GROUP_ALL_TIMELINE_KINDS, - tags = mapOf(GroupIdTag.TAG_NAME to ids.distinct()), - since = sinceEpoch, - ), - ) - } +): RelayBasedFilter = + RelayBasedFilter( + relay = groupId.relayUrl, + filter = + Filter( + kinds = RELAY_GROUP_ALL_TIMELINE_KINDS, + tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)), + since = sinceEpoch, + ), + ) + +/** + * The **newest message** of one joined channel, at any age: single-valued `#h`, `limit = 1`, and a + * `since` that is null until this relay EOSEs. + * + * This is what fills the channel's Messages-list row, and it is deliberately count-bounded rather than + * time-bounded. [buildRelayGroupJoinedChatTailFilter] floors at `now - 7 days` to keep recent chat warm + * in cache; on its own that means a channel nobody has posted in for eight days returns *zero* events, + * so `newestChatNote()` stays null and the row is stuck on its "No messages yet" placeholder forever — + * sorted to the bottom of Messages by `createdAt = 0`, indistinguishable from a channel you just joined. + * + * Every other roster-driven protocol on that screen already bounds by count for exactly this reason: + * NIP-28 asks `limit = 1` per followed channel + * ([com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.filterLastMessageFollowingPublicChats]), + * Concord asks `limit = 10` per channel with no floor (`ConcordSubscriptionPlanner.channelPreviewFilters`). + * They can, because their row set comes from a list event — unlike NIP-17/NIP-04, whose *rooms* are + * discovered from the messages themselves and therefore need the backward pagers on the Messages list. + * A NIP-29 channel is roster-driven (kind 10009), so it needs one newest message per row, not a window. + * + * `since` comes from the per-relay EOSE map: null on a cold start (newest message whatever its age), + * then advancing, so a reconnect re-asks for one event rather than replaying a window. + */ +fun buildRelayGroupPreviewFilter( + groupId: GroupId, + sinceEpoch: Long?, +): RelayBasedFilter = + RelayBasedFilter( + relay = groupId.relayUrl, + filter = + Filter( + kinds = RELAY_GROUP_ALL_TIMELINE_KINDS, + tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)), + since = sinceEpoch, + limit = 1, + ), + ) + +/** + * Reactions/deletions for **every** message in one channel, `#h`-scoped on its host relay — see + * [RELAY_GROUP_AUX_KINDS]. Single-valued `#h`, so it can share a channel-scoped subscription with the + * chat tail without downgrading it. + */ +fun buildRelayGroupAuxFilter( + groupId: GroupId, + sinceEpoch: Long, +): RelayBasedFilter = + RelayBasedFilter( + relay = groupId.relayUrl, + filter = + Filter( + kinds = RELAY_GROUP_AUX_KINDS, + tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)), + since = sinceEpoch, + limit = RELAY_GROUP_AUX_LIMIT, + ), + ) /** The recent-chat live tail for a single open group, `#h`-scoped on its host relay. */ fun buildRelayGroupOpenChatTailFilter( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupJoinedChatTailFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupJoinedChatTailFilterAssembler.kt index 8a6e02f52e..f0279dca67 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupJoinedChatTailFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupJoinedChatTailFilterAssembler.kt @@ -27,19 +27,21 @@ import com.vitorpamplona.amethyst.commons.relayClient.paging.WindowLoadTracker import com.vitorpamplona.amethyst.commons.relayClient.paging.trackingListener import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.launchChatFeedToggleObserver +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.filterGroupNotificationsToPubkey import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip29RelayGroups.GroupId import com.vitorpamplona.quartz.utils.TimeUtils -import kotlinx.coroutines.Job import kotlinx.coroutines.flow.StateFlow -/** One screen's request to keep the user's joined groups' recent chat live. */ +/** One joined group whose recent chat is kept live for the Messages-list preview. */ class RelayGroupJoinedChatTailQueryState( val account: Account, + val groupId: GroupId, ) /** @@ -47,15 +49,17 @@ class RelayGroupJoinedChatTailQueryState( * analog of the NIP-04 rooms-list live tail * ([com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListNip04SubAssembler]). * - * One `#h`-scoped filter **per host relay** carries every joined group id on that relay at once, - * `since = recentBoundary()` and **no per-group limit** — a time floor bounds it, so unlike the fixed - * `limit=50` window it can batch all of a relay's groups into a single REQ. This keeps the Messages-list - * previews reflecting the true newest message and joined groups' recent chat warm in cache without + * **One subscription per joined group**, each a single-valued `#h` filter with `since = recentBoundary()` + * and no `limit` — a time floor bounds it, so it stays reconnect-safe. This keeps the Messages-list + * previews reflecting the true newest message, and joined groups' recent chat warm in cache, without * opening each one. Older history is the [RelayGroupOpenChatHistorySubAssembler]'s job (below the floor). * - * Batching by a shared time floor (rather than a per-group `limit` + shared per-relay EOSE `since`) is - * what makes this both correct — every joined group is covered the moment it joins — and reconnect-safe: - * a reconnect re-issues one `since=window` REQ per relay, never a per-group page replay. + * These used to be batched into one subscription per host relay carrying every group id in a single `#h`. + * That is valid NIP-01 and relays answer it correctly for stored queries, but it silently breaks *live* + * delivery on `block/buzz`, which can only index a subscription under one channel and downgrades a + * multi-id one to "global" — a class that by design receives no channel-scoped events. The batched tail + * therefore backfilled at EOSE and then went permanently deaf, freezing the Messages row while the open + * chat (always single-valued) stayed live. See [buildRelayGroupJoinedChatTailFilter] for the full trace. */ class RelayGroupJoinedChatTailFilterAssembler( client: INostrClient, @@ -74,7 +78,7 @@ class RelayGroupJoinedChatTailFilterAssembler( class RelayGroupJoinedChatTailSubAssembler( client: INostrClient, allKeys: () -> Set, -) : PerUniqueIdEoseManager(client, allKeys) { +) : PerUniqueIdEoseManager(client, allKeys) { private val windowLoad = WindowLoadTracker("relayGroup.preview.live") val loadingMore: StateFlow = windowLoad.loading @@ -82,42 +86,51 @@ class RelayGroupJoinedChatTailSubAssembler( key: RelayGroupJoinedChatTailQueryState, since: SincePerRelayMap?, ): List? { - if (!key.account.settings.isChatFeedEnabled(ChatFeedType.NIP29)) { - windowLoad.setExpectedRelays(emptySet()) - return null - } - val joined = key.account.relayGroupList.liveRelayGroupList.value - if (joined.isEmpty()) { - windowLoad.setExpectedRelays(emptySet()) - return null - } + // The preload only mounts keys while the toggle is on; re-checked here so a flip to off + // empties any subscription that outlives the recomposition. + if (!key.account.settings.isChatFeedEnabled(ChatFeedType.NIP29)) return null - // One #h filter per host relay carrying every joined group id on it; bounded by the shared - // recent-tail floor, so no per-group limit and no per-group re-subscribe on join. - val filters = buildRelayGroupJoinedChatTailFilters(joined, DmHistoryTuning.recentBoundary()) - windowLoad.setExpectedRelays(filters.mapTo(mutableSetOf()) { it.relay }) - return filters + val relay = key.groupId.relayUrl + // The tracker is shared by every key of this assembler, so it must describe the whole joined + // fleet rather than whichever channel happened to rebuild last — otherwise each key would + // overwrite the expected set with its own single relay. [loadingMore] is what the Messages rows + // read to tell "still fetching this channel's newest message" apart from "channel is empty". + windowLoad.setExpectedRelays( + key.account.relayGroupList.liveRelayGroupList.value + .mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it.relayUrl) }, + ) + + // Two filters, both `#h`-scoped to THIS group, so the subscription still resolves to a single + // channel on buzz (its resolver only bails when two *distinct* ids appear, or when some filter + // carries no channel tag at all). Each is queried independently, so they keep their own window: + // - the chat tail: every timeline kind, time-floored, no limit + // - group activity addressed to me: reactions/zaps/reports/replies (kinds the tail doesn't ask + // for), `#p` = me + `limit`, `since` from EOSE so a cold start is all-time and a reconnect is + // cheap. This used to ride the account-wide notifications subscription, which also carries + // inbox filters with no `#h` — that alone forces the whole subscription global on buzz, so + // live group reactions never arrived until the next launch. + return listOf( + buildRelayGroupJoinedChatTailFilter(key.groupId, DmHistoryTuning.recentBoundary()), + // The row's newest message at ANY age. The tail above floors at 7 days to keep recent chat + // warm, which on its own strands a channel quiet for longer than that on a placeholder row. + buildRelayGroupPreviewFilter(key.groupId, since?.get(relay)?.time), + // Reactions/deletions for every message in this channel — the shape Buzz's own client uses. + buildRelayGroupAuxFilter(key.groupId, DmHistoryTuning.recentBoundary()), + ) + + filterGroupNotificationsToPubkey( + relay = relay, + pubkey = key.account.userProfile().pubkeyHex, + groupIds = listOf(key.groupId.id), + since = since?.get(relay)?.time, + ) } - override fun id(key: RelayGroupJoinedChatTailQueryState) = key.account - - private val toggleJobs = mutableMapOf() + override fun id(key: RelayGroupJoinedChatTailQueryState) = key.groupId override fun newSub(key: RelayGroupJoinedChatTailQueryState): Subscription { windowLoad.startLoading(key.account.scope) - toggleJobs.remove(key.account)?.cancel() - toggleJobs[key.account] = - key.account.scope.launchChatFeedToggleObserver(key.account, ChatFeedType.NIP29) { invalidateFilters() } return requestNewSubscription( windowLoad.trackingListener { relay: NormalizedRelayUrl, filters -> newEose(key, relay, TimeUtils.now(), filters) }, ) } - - override fun endSub( - key: Account, - subId: String, - ) { - super.endSub(key, subId) - toggleJobs.remove(key)?.cancel() - } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenChatTailFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenChatTailFilterAssembler.kt index 63c08e0b5d..af33acea2e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenChatTailFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenChatTailFilterAssembler.kt @@ -76,7 +76,12 @@ class RelayGroupOpenChatTailSubAssembler( since: SincePerRelayMap?, ): List { windowLoad.setExpectedRelays(setOf(key.groupId.relayUrl)) - return listOf(buildRelayGroupOpenChatTailFilter(key.groupId, DmHistoryTuning.recentBoundary())) + return listOf( + buildRelayGroupOpenChatTailFilter(key.groupId, DmHistoryTuning.recentBoundary()), + // Reactions/deletions for every message on screen, `#h`-scoped rather than `#e`-scoped — + // see [RELAY_GROUP_AUX_KINDS]. Covers a non-joined group opened by link too. + buildRelayGroupAuxFilter(key.groupId, DmHistoryTuning.recentBoundary()), + ) } override fun id(key: RelayGroupOpenChatTailQueryState) = key.groupId diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt index 05ab71f7d3..30512735c9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt @@ -456,11 +456,21 @@ private fun RelayGroupRoomCompose( // A Buzz timeline row (system line, huddle/job activity, diff) carries JSON/diff in its // content, so show its human-readable summary — the same text the in-chat row renders — // rather than "author: {json}". Plain chat messages fall through to the usual framing. - buzzTimelinePreviewSummary(noteEvent) ?: "$authorName: ${noteEvent.content.take(200)}" + buzzTimelinePreviewSummary(noteEvent, accountViewModel) ?: "$authorName: ${noteEvent.content.take(200)}" } else { - // Event-less placeholder row for a just-joined group with no messages yet — say so - // explicitly (like Marmot groups) instead of an empty second line. - stringRes(R.string.relay_group_no_messages_yet) + // Event-less placeholder row. Until the channel's `limit = 1` preview REQ settles we cannot + // tell an empty channel from one whose newest message simply hasn't arrived, and claiming + // "No messages yet" for a busy channel reads as a bug — so say "Loading" while the joined + // fleet is still fetching, and commit to the empty wording only once it has. + val stillFetching by accountViewModel + .dataSources() + .relayGroupJoinedChatTail.tail.loadingMore + .collectAsStateWithLifecycle() + if (stillFetching) { + stringRes(R.string.loading_feed) + } else { + stringRes(R.string.relay_group_no_messages_yet) + } } val groupPicture = channel.profilePicture()?.ifBlank { null } @@ -594,7 +604,7 @@ private fun RelayGroupServerRoomCompose( val authorName by observeUserName(author, accountViewModel) // Buzz timeline rows (system/huddle/job/diff) carry JSON/diff content — summarize them // like the in-chat row instead of printing raw payload; plain chat falls through. - buzzTimelinePreviewSummary(noteEvent) ?: "$authorName: ${noteEvent.content.take(200)}" + buzzTimelinePreviewSummary(noteEvent, accountViewModel) ?: "$authorName: ${noteEvent.content.take(200)}" } else { stringRes(R.string.relay_group_no_messages_yet) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomRowUnread.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomRowUnread.kt new file mode 100644 index 0000000000..f84fa24049 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomRowUnread.kt @@ -0,0 +1,100 @@ +/* + * 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.chats.rooms + +import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel +import com.vitorpamplona.amethyst.commons.model.geohashChat.GeohashChatChannel +import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.unreadPrivateChatRoute +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.marmotGroupLastReadRoute +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.concordChannelLastReadRoute +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.concordCommunityHasUnreadFlow +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.relayGroupChannelLastReadRoute +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.relayGroupServerHasUnreadFlow +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ConcordServerRoomNote +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.RelayGroupServerRoomNote +import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChatEvent +import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent +import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +/** + * Whether one Messages row is showing a blue dot — the SAME question each row composable answers for + * itself in [ChatroomHeaderCompose], hoisted so the bottom-bar badge can ask it about every row. + * + * The badge used to run on `unreadPrivateChatRoute` alone, which opens with + * `if (newestMessage !is ChatroomKeyable) return null`. Only NIP-17/NIP-04 DM events implement that + * interface, so eight of the nine row types — public chats, ephemeral rooms, geohash cells, Marmot + * groups, NIP-29/Buzz channels and Concord channels, plus both collapsed "grouped" rows — were + * silently skipped: their row could show a dot while the envelope stayed clean. + * + * Returns null when the row cannot be unread at all (no event, my own newest message in a DM, everyone + * hidden), so callers can skip it rather than subscribe to a flow that is always false. + * + * The two collapsed rows are why this returns a `Flow` rather than a `(route, createdAt)` + * pair: their dot is a fan-in over every child channel, not one timestamp against one marker, and + * approximating them by the newest child would miss an older channel that is still unread. + */ +fun rowHasUnreadFlow( + row: Note, + account: Account, +): Flow? { + // Collapsed rows own a fan-in flow across their children — reuse the row's own signal verbatim. + if (row is RelayGroupServerRoomNote) return relayGroupServerHasUnreadFlow(account, row.relay) + if (row is ConcordServerRoomNote) return concordCommunityHasUnreadFlow(account, row.communityId) + + val route = rowLastReadRoute(row, account) ?: return null + val createdAt = row.createdAt() ?: return null + return account.settings.getLastReadFlow(route).map { lastReadAt -> createdAt > lastReadAt } +} + +/** + * The last-read marker route behind a row's dot, mirroring what each row composable loads. Channel-type + * rows are identified by their gatherer (the channel the note was filed into) rather than by event kind, + * because a Buzz channel and a Concord channel can both carry a kind-9 message. + */ +private fun rowLastReadRoute( + row: Note, + account: Account, +): String? { + row.inGatherers?.forEach { gatherer -> + when (gatherer) { + is RelayGroupChannel -> return relayGroupChannelLastReadRoute(gatherer.groupId) + is ConcordChannel -> return concordChannelLastReadRoute(gatherer.channelId.communityId, gatherer.channelId.channelId) + is MarmotGroupChatroom -> return marmotGroupLastReadRoute(gatherer.nostrGroupId) + is GeohashChatChannel -> return "Geohash/${gatherer.geohash}" + else -> Unit + } + } + + return when (val event = row.event) { + // Same route strings the row composables use — see ChatroomHeaderCompose. + is ChannelMessageEvent -> event.channelId()?.let { "Channel/$it" } + is EphemeralChatEvent -> event.roomId()?.let { "Channel/${it.toKey()}" } + is GeohashChatEvent -> event.geohash()?.let { "Geohash/$it" } + // DMs keep their own rule: a room whose newest message is mine counts as read. + else -> unreadPrivateChatRoute(row.event, account.signer.pubKey, account::isAllHidden)?.first + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt index 5ec4afb1a3..af44f5e0ca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt @@ -33,6 +33,8 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.isConcordTimelineMessage +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.isRelayGroupTimelineMessage +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.newestTimelineNote import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChatEvent import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent @@ -157,7 +159,7 @@ class ChatroomListKnownFeedFilter( // Newest loaded chat message, or a placeholder row so a just-joined group shows // up on Messages before its first kind-9 arrives (mirrors the Marmot-group path // above). Content kinds only — never a reaction/deletion as the "last message". - channel.newestChatNote(account) ?: channel.placeholderNote() + channel.newestTimelineNote(account) ?: channel.placeholderNote() } RelayGroupViewMode.GROUPED -> @@ -169,7 +171,7 @@ class ChatroomListKnownFeedFilter( val relay = RelayUrlNormalizer.normalizeOrNull(relayUrl) ?: return@mapNotNull null val newest = tags - .mapNotNull { LocalCache.getOrCreateRelayGroupChannel(GroupId(it.groupId, relay)).newestChatNote(account) } + .mapNotNull { LocalCache.getOrCreateRelayGroupChannel(GroupId(it.groupId, relay)).newestTimelineNote(account) } .maxByOrNull { it.createdAt() ?: 0L } RelayGroupServerRoomNote(relay, newest) } @@ -489,13 +491,6 @@ class ChatroomListKnownFeedFilter( return newRelevantEphemeralChats } - /** The newest actual chat message loaded in this group's channel, or null if none yet. */ - private fun RelayGroupChannel.newestChatNote(account: Account): Note? = - notes - .filter { _, it -> account.isAcceptable(it) && it.event?.isGroupChatContent() == true } - .sortedByDefaultFeedOrder() - .firstOrNull() - /** * The newest decrypted *timeline* message loaded in this Concord channel, or null if none yet. * Uses [isConcordTimelineMessage] so a trailing kind-1111 thread reply (or a hidden author) @@ -540,8 +535,8 @@ class ChatroomListKnownFeedFilter( val joinedGroupIds = joined.mapTo(HashSet()) { it.groupId } val result = mutableMapOf() newItems.forEach { newNote -> - val gid = newNote.event?.takeIf { it.isGroupChatContent() }?.groupId() - if (gid != null && gid in joinedGroupIds && account.isAcceptable(newNote)) { + val gid = newNote.event?.takeIf { isRelayGroupTimelineMessage(newNote, account) }?.groupId() + if (gid != null && gid in joinedGroupIds) { val lastNote = result[gid] if (lastNote == null || (newNote.createdAt() ?: 0L) > (lastNote.createdAt() ?: 0L)) { result[gid] = newNote @@ -559,9 +554,8 @@ class ChatroomListKnownFeedFilter( // Newest new message per host relay, collapsed into one per-relay row. val newestPerRelay = HashMap() newItems.forEach { newNote -> - val gid = newNote.event?.takeIf { it.isGroupChatContent() }?.groupId() ?: return@forEach + val gid = newNote.event?.takeIf { isRelayGroupTimelineMessage(newNote, account) }?.groupId() ?: return@forEach val relay = groupToRelay[gid] ?: return@forEach - if (!account.isAcceptable(newNote)) return@forEach val lastNote = newestPerRelay[relay] if (lastNote == null || (newNote.createdAt() ?: 0L) > (lastNote.createdAt() ?: 0L)) { newestPerRelay[relay] = newNote diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt index 217cb50f7d..c8fafc54ec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt @@ -21,8 +21,10 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.feed import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed @@ -85,6 +87,13 @@ fun ChatroomListFeedView( scrollStateKey: String, accountViewModel: AccountViewModel, nav: INav, + /** + * Pinned above the rows. Rendered INSIDE the feed rather than beside it so it inherits + * [rememberFeedContentPadding] — the collapsing top bar draws over this area, and a header placed + * outside the list lands underneath it. Shown in every state, so a standing prompt is still + * reachable when the list itself is empty. + */ + headerContent: (@Composable () -> Unit)? = null, ) { DisposableEffect(Unit) { Log.d("DMPagination") { "rooms.list: OPEN" } @@ -92,7 +101,7 @@ fun ChatroomListFeedView( } RefresheableBox(feedContentState, true) { SaveableFeedContentState(feedContentState, scrollStateKey) { listState -> - CrossFadeState(feedContentState, listState, accountViewModel, nav) + CrossFadeState(feedContentState, listState, accountViewModel, nav, headerContent) } } } @@ -103,6 +112,7 @@ private fun CrossFadeState( listState: LazyListState, accountViewModel: AccountViewModel, nav: INav, + headerContent: (@Composable () -> Unit)? = null, ) { val feedState by feedContentState.feedContent.collectAsStateWithLifecycle() @@ -131,10 +141,13 @@ private fun CrossFadeState( ) { state -> when (state) { is FeedState.Empty -> { - if (historyExhausted) { - FeedEmpty { feedContentState.invalidateData() } - } else { - LoadingFeed() + Column(Modifier.padding(rememberFeedContentPadding(FeedPadding))) { + headerContent?.invoke() + if (historyExhausted) { + FeedEmpty { feedContentState.invalidateData() } + } else { + LoadingFeed() + } } } @@ -143,7 +156,7 @@ private fun CrossFadeState( } is FeedState.Loaded -> { - FeedLoaded(state, listState, accountViewModel, nav) + FeedLoaded(state, listState, accountViewModel, nav, headerContent) } FeedState.Loading -> { @@ -159,6 +172,7 @@ private fun FeedLoaded( listState: LazyListState, accountViewModel: AccountViewModel, nav: INav, + headerContent: (@Composable () -> Unit)? = null, ) { val items by loaded.feed.collectAsStateWithLifecycle() @@ -214,6 +228,8 @@ private fun FeedLoaded( contentPadding = rememberFeedContentPadding(FeedPadding), state = listState, ) { + headerContent?.let { item("chatroom-list-header") { it() } } + itemsIndexed( items.list, key = { _, item -> chatroomLazyKey(item, myPubKey) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt index 157702ec24..fcd6310c2b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt @@ -49,6 +49,7 @@ import com.vitorpamplona.amethyst.ui.components.M3ActionSection import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.zonedDrawerSwipeIfModal import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.ChannelInvitesSection import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size40dp import com.vitorpamplona.amethyst.ui.theme.TabRowHeight @@ -131,6 +132,17 @@ fun MessagesPager( scrollStateKey = tabs[page].scrollStateKey, accountViewModel = accountViewModel, nav = nav, + // Channels somebody added you to are pending decisions, exactly like an unaccepted DM — so + // they belong on New Requests, pinned above the rows. Passed as the feed's header rather + // than stacked beside it: the collapsing top bar draws over this area, so a header outside + // the list renders underneath it. The Notifications tab shows the same prompts from the + // same state holder, so the two surfaces cannot disagree. + headerContent = + if (tabs[page].resource == R.string.new_requests) { + { ChannelInvitesSection(accountViewModel) } + } else { + null + }, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/NestRoomFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/NestRoomFilterAssembler.kt index e14ee14a33..43788f33b2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/NestRoomFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/NestRoomFilterAssembler.kt @@ -39,7 +39,7 @@ import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessa import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent -import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent /** * Per-room state for every wire subscription scoped to a single diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lifecycle/NestRoomEventCollectors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lifecycle/NestRoomEventCollectors.kt index c1c2c8ef9d..82fd14e0b9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lifecycle/NestRoomEventCollectors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lifecycle/NestRoomEventCollectors.kt @@ -33,7 +33,7 @@ import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessa import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent -import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.delay import kotlinx.coroutines.isActive diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/ChannelInvitesSection.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/ChannelInvitesSection.kt new file mode 100644 index 0000000000..caf6d6c1bb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/ChannelInvitesSection.kt @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvite +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl +import com.vitorpamplona.quartz.nip29RelayGroups.GroupId + +/** + * "Somebody added you to a channel" prompts, rendered above the Notifications feed (the same header slot + * the missing-inbox-relay prompt uses) and inside Messages › New Requests. + * + * These are deliberately NOT auto-accepted. On a Buzz relay another member can add you to a channel + * server-side: the relay writes you into the kind-39002 roster and you can immediately read and post, + * without you ever agreeing to see it. Amethyst used to silently subscribe to those channels' messages + * while showing no row for them anywhere, so a channel could be joined, streaming, and invisible at once. + * Now the relay's decision is surfaced as a question instead of being acted on. + */ +@Composable +fun ChannelInvitesSection( + accountViewModel: AccountViewModel, + modifier: Modifier = Modifier, +) { + val invites by accountViewModel.feedStates.channelInvites.flow + .collectAsStateWithLifecycle() + + if (invites.isEmpty()) return + + Column(modifier) { + invites.forEach { invite -> + ChannelInviteCard(invite, accountViewModel) + } + } +} + +@Composable +fun ChannelInviteCard( + invite: BuzzChannelInvite, + accountViewModel: AccountViewModel, +) { + val channel = remember(invite.channelId, invite.relay) { LocalCache.getOrCreateRelayGroupChannel(GroupId(invite.channelId, invite.relay)) } + + val actorUser = remember(invite.actor) { invite.actor?.let { LocalCache.getOrCreateUser(it) } } + val actorName = actorUser?.let { observeUserName(it, accountViewModel).value } + + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 5.dp), + ) { + Column(Modifier.padding(12.dp)) { + Text( + text = stringRes(R.string.channel_invite_title, channel.toBestDisplayName()), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + ) + Text( + // Who did it matters: the relay reports a self-join with the same event, so naming the + // actor is what tells "I joined this" apart from "a stranger put me here". + text = + stringRes( + R.string.channel_invite_body, + actorName ?: stringRes(R.string.channel_invite_unknown_actor), + invite.relay.displayUrl(), + ), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(top = 4.dp), + ) + + Row( + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(top = 6.dp), + ) { + // Leave is separate from Ignore on purpose: Ignore is a local display choice that leaves + // you in the roster, Leave is the kind-9022 that actually removes you from the channel. + TextButton(onClick = { accountViewModel.leaveChannelInvite(channel) }) { + Text(stringRes(R.string.channel_invite_leave), color = MaterialTheme.colorScheme.error) + } + TextButton(onClick = { accountViewModel.dismissChannelInvite(invite.channelId) }) { + Text(stringRes(R.string.channel_invite_ignore)) + } + TextButton(onClick = { accountViewModel.acceptChannelInvite(channel) }) { + Text(stringRes(R.string.channel_invite_accept), fontWeight = FontWeight.Bold) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/ChannelInvitesState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/ChannelInvitesState.kt new file mode 100644 index 0000000000..7b8c39a911 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/ChannelInvitesState.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications + +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvite +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvites +import com.vitorpamplona.amethyst.model.Account +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn + +/** + * The channels somebody else added the viewer to that are still awaiting a decision. + * + * An entry drops out the moment it stops being a question: accepting writes the group into kind-10009 + * (so `joined` covers it and the ordinary Messages row takes over), dismissing records the channel in + * `dismissedChannelInvites`, and leaving makes the relay withdraw the membership. Nothing here asserts + * membership — the relay already granted that — it only tracks whose call it is to surface the channel. + * + * Modelled on [OpenPollsState]: a small always-on projection the Notifications screen and the Messages + * "New Requests" tab both render, so the two surfaces can never disagree about what is pending. + */ +@Stable +class ChannelInvitesState( + private val account: Account, + scope: CoroutineScope, +) { + val flow: StateFlow> = + combine( + BuzzChannelInvites.flow.map { it[account.userProfile().pubkeyHex] ?: emptyMap() }, + account.settings.dismissedChannelInvites, + account.relayGroupList.liveRelayGroupList, + ) { invites, dismissed, joined -> + val joinedIds = joined.mapTo(HashSet()) { it.groupId } + invites.values + .filter { it.channelId !in dismissed && it.channelId !in joinedIds } + .sortedByDescending { it.createdAt } + }.flowOn(Dispatchers.IO) + .stateIn(scope, SharingStarted.Eagerly, emptyList()) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt index b226f9d3df..627984e45c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt @@ -242,7 +242,12 @@ internal fun SingleNotificationsBody( nav = nav, routeForLastRead = NOTIFICATION_LAST_READ_KEY, scrollToEventId = scrollToEventId, - headerContent = { ObserveInboxRelayListAndDisplayIfNotFound(accountViewModel, nav) }, + headerContent = { + ObserveInboxRelayListAndDisplayIfNotFound(accountViewModel, nav) + // "X added you to #channel" prompts sit above the feed rather than inside it: they are a + // standing decision, not a dated event, so they must not scroll away into history. + ChannelInvitesSection(accountViewModel) + }, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSummaryState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSummaryState.kt index 6ab60f051e..e2c7a550bd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSummaryState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSummaryState.kt @@ -40,8 +40,8 @@ import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent -import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt index 82c77a5d60..3c5083986a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt @@ -35,6 +35,8 @@ import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder import com.vitorpamplona.quartz.buzz.jobs.JobErrorEvent import com.vitorpamplona.quartz.buzz.jobs.JobResultEvent import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event +import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply +import com.vitorpamplona.quartz.buzz.threading.buzzThreadRoot import com.vitorpamplona.quartz.buzz.workflow.WorkflowApprovalRequestedEvent import com.vitorpamplona.quartz.buzz.workspace.buzzParticipants import com.vitorpamplona.quartz.buzz.workspace.isBuzzDm @@ -87,11 +89,11 @@ import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent -import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -151,10 +153,16 @@ class NotificationFeedFilter( // filterGroupNotificationsToPubkey. // • NIP-C7 / Concord: an inline reply (Concord's default reply mode) or an @-mention // p-tags me; a minichat reply is a kind-1111 CommentEvent below. - // Either way it notifies only when it p-tags me — a plain channel message tags no one - // and never reaches here. Without kind 9 the acceptableEvent kind gate would drop these - // replies before the p-tag check, so they'd never render on the Notifications tab. + // • Buzz channels: a THREAD reply carries no `p` tag at all (Buzz only p-tags + // @mentions), so it qualifies through [isBuzzThreadReplyToMyEvent] instead, which + // resolves the author of its `root`/`reply` marked `e` tags. + // A plain channel message tags no one and matches none of those, so it never reaches + // here. Without kind 9 the acceptableEvent kind gate would drop these replies before + // any of the checks, so they'd never render on the Notifications tab. ChatEvent.KIND, + // Legacy Buzz thread replies (nothing writes 40002 any more, but they exist in the + // wild). Same no-`p`-tag shape as kind 9 above. + StreamMessageV2Event.KIND, ChatMessageEvent.KIND, ChatMessageEncryptedFileHeaderEvent.KIND, CommentEvent.KIND, @@ -420,6 +428,36 @@ class NotificationFeedFilter( ?.pubkeyHex == me } + /** + * A Buzz chat **thread reply** into one of my messages, when the reply carries no `p` tag. + * + * Buzz's clients thread with `["e", , "", "reply"]` (nested: `root` + `reply`) and only ever + * `p`-tag @mentions, so a reply to my message names me nowhere. That is the same shape as a Buzz + * reaction, which [isReactionToMyEvent] already rescues by resolving the target's author instead of + * trusting a tag — this does the same for replies, looking at the author of the `root`/`reply` + * targets specifically (never a bare `e`, which is WhiteNoise/Marmot's in-chat reply, not a thread). + * + * Without it a reply to my message in a Buzz channel notifies nothing — and since a thread reply is + * deliberately kept out of the channel timeline and its unread dot + * ([com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.isRelayGroupTimelineMessage]), + * it would be invisible on every surface. + * + * Only consulted when there is no `p` tag: a reply that does name me already passes the p-tag gate. + */ + private fun isBuzzThreadReplyToMyEvent( + note: Note, + me: HexKey, + ): Boolean { + val event = note.event + if (event !is ChatEvent && event !is StreamMessageV2Event) return false + if (event.tags.any { it.getOrNull(0) == "p" }) return false + + val threadTargets = setOfNotNull(event.tags.buzzThreadReply(), event.tags.buzzThreadRoot()) + if (threadTargets.isEmpty()) return false + + return note.replyTo?.any { it.idHex in threadTargets && it.author?.pubkeyHex == me } == true + } + fun acceptableEvent( it: Note, filterParams: FilterByListParams, @@ -555,6 +593,9 @@ class NotificationFeedFilter( // exactly like a Concord reaction, since being the author of the liked post is the only signal. val isReactionToMe = isReactionToMyEvent(it, loggedInUserHex) + // Same no-`p`-tag rescue for Buzz thread replies into my messages. + val isThreadReplyToMe = isBuzzThreadReplyToMyEvent(it, loggedInUserHex) + // Concord CHAT (a message/reply) honors the "Messages in notifications" toggle that silences DMs // and Marmot groups above. A reaction isn't a message — regular reactions ignore that toggle, so // Concord reactions do too (only isConcordMessage is gated). @@ -573,14 +614,14 @@ class NotificationFeedFilter( // to genuine replies, so unrelated channel chatter never leaks through. return noteEvent?.kind in NOTIFICATION_KINDS && (noteEvent is LnZapEvent || noteEvent is Bolt12ZapEvent || notifAuthor != loggedInUserHex) && - (isChessEvent || isConcord || isReactionToMe || filterParams.isGlobal() || notifAuthor == null || filterParams.isAuthorInFollows(notifAuthor)) && - (noteEvent?.isTaggedUser(loggedInUserHex) == true || isNotifiablePublicChatReply(it, loggedInUserHex) || isReactionToMe) && + (isChessEvent || isConcord || isReactionToMe || isThreadReplyToMe || filterParams.isGlobal() || notifAuthor == null || filterParams.isAuthorInFollows(notifAuthor)) && + (noteEvent?.isTaggedUser(loggedInUserHex) == true || isNotifiablePublicChatReply(it, loggedInUserHex) || isReactionToMe || isThreadReplyToMe) && (filterParams.isHiddenList || notifAuthor == null || !account.isHidden(notifAuthor)) && (noteEvent !is PrivateDmEvent || !account.isDecryptedContentHidden(noteEvent)) && // For a Concord note the explicit p-tag above IS the relevance signal (the reply/reaction/ // mention targets me directly), so skip the per-kind heuristic — which for a reaction would // otherwise need my target message already loaded to resolve replyTo. - (isRawGlobal || isConcord || tagsAnEventByUser(it, loggedInUserHex)) + (isRawGlobal || isConcord || isThreadReplyToMe || tagsAnEventByUser(it, loggedInUserHex)) } override fun sort(items: Set): List = items.sortedByDefaultFeedOrder() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileZapReceived.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileZapReceived.kt index 13f7d1f4d8..f9fb0bbc74 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileZapReceived.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileZapReceived.kt @@ -26,8 +26,8 @@ import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent -import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent val UserProfileZapReceiverKinds = listOf(LnZapEvent.KIND, OnchainZapEvent.KIND, Bolt12ZapEvent.KIND) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/Bolt12PayButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/Bolt12PayButton.kt index ec6119d01e..cfba34cc31 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/Bolt12PayButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/Bolt12PayButton.kt @@ -68,7 +68,7 @@ import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.ZeroPadding -import com.vitorpamplona.quartz.nipXXBolt12Zaps.offer.Bolt12OfferListEvent +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent import kotlinx.coroutines.launch @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/dal/UserProfileZapsViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/dal/UserProfileZapsViewModel.kt index 5a8292d3c2..bd3ed286dd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/dal/UserProfileZapsViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/dal/UserProfileZapsViewModel.kt @@ -31,8 +31,8 @@ import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent -import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.utils.BigDecimal import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.SharingStarted diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/KindDisplayName.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/KindDisplayName.kt index 59108d02b0..97111960be 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/KindDisplayName.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/KindDisplayName.kt @@ -163,13 +163,13 @@ import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent -import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent /** Returns the `@StringRes` id for the translated kind name, or -1 if unknown. */ @Suppress("DEPRECATION") diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index 264741738b..6e603ab36a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -344,12 +344,12 @@ import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent -import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.Podcasting20PodcastMetadata import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/ArtiGuardState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/ArtiGuardState.kt index ace49ffcaf..5ceab6f135 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/ArtiGuardState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/ArtiGuardState.kt @@ -48,12 +48,34 @@ object ArtiGuardState { fun parse(json: String): JsonNode = mapper.readTree(json) /** - * True when at least one non-empty guard selection has *zero* usable guards — - * every guard permanently `disabled` or dropped from the consensus - * (`unlisted_since`). This is the AllGuardsDown wedge: Arti can neither build - * circuits nor replenish the sample (it's full of unusable entries), so it - * stays broken across restarts until the on-disk state is wiped. A single - * usable guard is enough to recover, so this only trips on a total wipeout. + * Smallest sample worth judging by ratio. Below this a low usable count is just a young + * sample that Arti is still filling, and wiping it would loop the bootstrap. + */ + const val MIN_SAMPLE_TO_JUDGE_RATIO = 10 + + /** A sample is wedged when fewer than 1 in [USABLE_RATIO_DIVISOR] of its guards are usable. */ + const val USABLE_RATIO_DIVISOR = 10 + + /** + * True when a non-empty guard selection has no usable guards left, or so few that Arti cannot + * realistically recover from them — every guard permanently `disabled` or dropped from the + * consensus (`unlisted_since`). + * + * This is the AllGuardsDown wedge: Arti can neither build circuits nor replenish the sample, so + * it stays broken across restarts until the on-disk state is wiped. + * + * ### Why this is not `usable == 0` + * + * It used to be, on the assumption that "a single usable guard is enough to recover". Observed in + * the field: a sample of 60 with **59 disabled and 1 usable**, while Arti rejected all sixty at + * runtime (`AllGuardsDown { n_accepted: 0, n_rejected: 60 }`) across repeated app restarts. The + * lone survivor was just as unreachable as the rest — useless for recovery, but enough to veto + * it, because `usable == 0` never became true. ~87% of relay connections failed indefinitely. + * + * So the test is proportional: a sample of at least [MIN_SAMPLE_TO_JUDGE_RATIO] whose usable + * guards are under 1/[USABLE_RATIO_DIVISOR] of the total is wedged. A small, young sample is + * still only judged by the strict `usable == 0` rule, so a legitimate first bootstrap that has + * sampled one or two guards is never wiped out from under itself. */ fun hasNoUsableGuards(root: JsonNode): Boolean { var wedged = false @@ -61,7 +83,11 @@ object ArtiGuardState { val guards = selection.get("guards") ?: return@forEach if (guards.isArray && guards.size() > 0) { val usable = guards.count { !it.isDisabled() && !it.isUnlisted() } - if (usable == 0) wedged = true + if (usable == 0) { + wedged = true + } else if (guards.size() >= MIN_SAMPLE_TO_JUDGE_RATIO && usable * USABLE_RATIO_DIVISOR < guards.size()) { + wedged = true + } } } return wedged diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorBackend.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorBackend.kt index 5d36defa88..ff75857e79 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorBackend.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorBackend.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.tor +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow /** @@ -46,4 +47,16 @@ interface TorBackend { * this is a `suspend` call. */ suspend fun hasBootstrappedBefore(): Boolean + + /** + * Emits when Arti has reported `AllGuardsDown` persistently enough that the guard sample is + * considered rotten at runtime, regardless of what `guards.json` claims. + * + * The on-disk heuristic ([ArtiGuardState.hasNoUsableGuards]) only sees guards Arti has + * permanently retired. A guard that is merely unreachable stays "usable" on disk forever, so a + * sample can be entirely dead in practice while still looking healthy to that check — which is + * exactly the state that survived repeated restarts in the field. This is the runtime half: + * Arti itself says every guard was rejected, so believe it. + */ + val guardsDownSignal: Flow } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt index f55ccf315c..932743bb65 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt @@ -250,6 +250,24 @@ class TorManager( if (it is TorServiceStatus.Active) hasEverBootstrapped = true }.launchIn(scope) + // Rotten guard sample while Tor is otherwise UP. The watchdog above only fires on a status + // stuck at Connecting, and the on-disk check only sees guards Arti has permanently retired — + // so a sample whose guards are all merely *unreachable* falls between them: status reaches + // Active, `guards.json` still lists usable entries, and every circuit fails anyway. Observed + // in the field surviving repeated restarts with ~87% of relay connections failing. Arti's own + // AllGuardsDown log is the only reliable signal, so route it through the same rate-limited + // wipe. Always a clean-state reset: the whole point is that the persisted sample is the + // problem. + service.guardsDownSignal + .onEach { + val now = nowMs() + if (now - lastSelfHealAtMs < SELF_HEAL_COOLDOWN_MS) return@onEach + lastSelfHealAtMs = now + Log.w("TorManager") { "Arti guard sample rotten at runtime — self-healing (drop client + wipe state)" } + service.resetWithCleanState() + resetEpoch.update { it + 1 } + }.launchIn(scope) + selfHealSignal .onEach { val now = nowMs() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt index bf3f7a245d..80fe1eacb7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt @@ -25,8 +25,12 @@ import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -34,6 +38,15 @@ import kotlinx.coroutines.withContext import java.io.File import java.util.concurrent.atomic.AtomicBoolean +/** Arti's log marker for "no usable guard could be found". */ +private const val ALL_GUARDS_DOWN_MARKER = "AllGuardsDown" + +/** How many [ALL_GUARDS_DOWN_MARKER] lines inside [GUARDS_DOWN_WINDOW_MS] mean the sample is rotten. */ +private const val GUARDS_DOWN_THRESHOLD = 40 + +/** Window for [GUARDS_DOWN_THRESHOLD]. Wide enough that ordinary transient churn never trips it. */ +private const val GUARDS_DOWN_WINDOW_MS = 60_000L + private const val DEFAULT_SOCKS_PORT = 17392 private const val MAX_PORT_RETRIES = 10 @@ -85,6 +98,47 @@ class TorService( private val _status = MutableStateFlow(TorServiceStatus.Off) override val status: StateFlow = _status.asStateFlow() + /** + * Runtime detector for a rotten guard sample. Arti logs [ALL_GUARDS_DOWN_MARKER] every time it + * fails to find a usable guard; [GUARDS_DOWN_THRESHOLD] of those inside [GUARDS_DOWN_WINDOW_MS] + * means every guard in the sample is unreachable *right now* — which the on-disk check cannot + * see, because an unreachable guard is not a `disabled` one (see + * [ArtiGuardState.hasNoUsableGuards]). + * + * Counted here because the log callback is the only place Arti surfaces it; acted on by + * [TorManager], which owns the reset cadence and its rate limiting. Both counters are only + * touched from Arti's log thread. + */ + private var guardsDownCount = 0 + + private var guardsDownWindowStartMs = 0L + + private val _guardsDownSignal = + MutableSharedFlow(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + + override val guardsDownSignal: Flow = _guardsDownSignal.asSharedFlow() + + /** + * Feeds one Arti log line to the [guardsDownSignal] detector. Cheap by design: this runs for + * every log line, and a wedged Arti emits tens of thousands of them. + */ + private fun trackGuardFailures(text: String) { + if (!text.contains(ALL_GUARDS_DOWN_MARKER)) return + + val now = System.currentTimeMillis() + if (now - guardsDownWindowStartMs > GUARDS_DOWN_WINDOW_MS) { + guardsDownWindowStartMs = now + guardsDownCount = 0 + } + guardsDownCount++ + if (guardsDownCount >= GUARDS_DOWN_THRESHOLD) { + guardsDownCount = 0 + guardsDownWindowStartMs = now + Log.w("TorService") { "Arti reported $ALL_GUARDS_DOWN_MARKER $GUARDS_DOWN_THRESHOLD times in ${GUARDS_DOWN_WINDOW_MS}ms — guard sample is rotten at runtime" } + _guardsDownSignal.tryEmit(Unit) + } + } + private fun artiDataDir() = File(context.filesDir, "arti") /** Diagnostic: total bytes of the consensus/descriptor cache, to correlate with bootstrap time. */ @@ -181,6 +235,7 @@ class TorService( // (gated on proxyRunning) intermittently dropped the transition. start() // now sets Active deterministically once the proxy is bound. ArtiNative.setLogCallback { text -> + trackGuardFailures(text) Log.d("TorService") { val newLine = text.indexOf('\n') if (newLine > 1) { diff --git a/amethyst/src/main/res/values-cs/strings.xml b/amethyst/src/main/res/values-cs/strings.xml index 7ff42d6b78..de567ca9b0 100644 --- a/amethyst/src/main/res/values-cs/strings.xml +++ b/amethyst/src/main/res/values-cs/strings.xml @@ -40,6 +40,7 @@ Dešifrování… + Vy: %1$s Obrázek skupiny Explicitní obsah Oznámení relé @@ -354,6 +355,18 @@ %1$d kanálu %1$d kanálů + + %1$d pracovní prostor + %1$d pracovní prostory + %1$d pracovního prostoru + %1$d pracovních prostorů + + + %1$d kanál + %1$d kanály + %1$d kanálu + %1$d kanálů + %1$d člen %1$d členové @@ -414,6 +427,7 @@ Vlákno + Odeslat obrázek %1$d odpověď @@ -1308,6 +1322,7 @@ Začít prostor Naplánovat na později Vyberte čas začátku + Konzole agenta Nest servery Vyberte, na které MoQ hostitelské servery Amethyst publikuje vaše nesty. První položka se použije jako výchozí při vytváření nového prostoru. Vaše servery @@ -1658,6 +1673,10 @@ Synchronizace Blossom Zobrazuje průběh kopírování vašich souborů mezi servery Blossom. Na vašich serverech Blossom nebyly nalezeny žádné uložené soubory. + Uloženo na + Podrobnosti souboru + Na všech serverech + Není na všech serverech Zrcadlit na chybějící Smazat z… Smazat z %1$s @@ -1677,6 +1696,39 @@ Nahlásit blob Důvod (nepovinné) Odeslat + Další akce + Importovat soubory… + Importovat soubory + Prohledejte ostatní Blossom servery a najděte soubory, které jste nahráli jinde, a zkopírujte je na své vlastní servery. + Servery k prohledání + Zapnout vše + Vypnout vše + Nebo vložte adresu serveru + Prohledat servery + Prohledávání… + Tento server je nedostupný + Na vybraných serverech nebyly nalezeny žádné nové soubory. + Nejprve přidejte vlastní Blossom servery, aby bylo kam importované soubory zkopírovat. + Spravovat mé servery + Synchronizace souborů už probíhá. Zkuste import znovu, až skončí. + + %1$d soubor + %1$d soubory + %1$d souboru + %1$d souborů + + + Nalezen %1$d soubor k importu na vaše servery. + Nalezeny %1$d soubory k importu na vaše servery. + Nalezeno %1$d souboru k importu na vaše servery. + Nalezeno %1$d souborů k importu na vaše servery. + + + Importovat %1$d soubor + Importovat %1$d soubory + Importovat %1$d souboru + Importovat %1$d souborů + Doporučené média servery Výchozí seznam Amethysty. Můžete je přidat jednotlivě nebo přidat seznam. Použít výchozí seznam @@ -1705,6 +1757,20 @@ Nebyla nalezena žádná aplikace pro zpracování této platby. Nainstalujte prosím kompatibilní peněženku. Není nainstalována žádná aplikace pro platby typu %1$s. Nainstalujte prosím kompatibilní peněženku. Nelze otevřít platbu + Nabídky BOLT12 + Zveřejněte znovupoužitelné nabídky BOLT12, aby vám ostatní mohli zapovat přes Lightning bez samostatné faktury pokaždé. + Přidejte jednu nebo více nabídek BOLT12 (lno1…). Jsou sdíleny veřejně, takže vám může kdokoli zaplatit. + Nejsou nastaveny žádné nabídky BOLT12. Přidejte jednu níže ↓ + Nabídka BOLT12 (lno1…) + Neplatná nabídka BOLT12 + Smazat nabídku BOLT12 + Zaplatit připojenou peněženkou + Částka (sats) + Platba BOLT12 odeslána + Platba BOLT12 selhala: %1$s + Chyba zapu BOLT12 + Zaplaceno přes BOLT12, ale peněženka nevrátila žádný důkaz, takže nebyla zveřejněna žádná zap účtenka. + Zaplaceno přes BOLT12, ale důkaz o platbě se nepodařilo ověřit, takže nebyla zveřejněna žádná zap účtenka. Nezačaté Kompresuje Nahrávání @@ -1811,6 +1877,10 @@ %1$s satů Od %1$s pro %1$s + Přijaté platby + Upozorní vás, když vaše připojená peněženka obdrží platbu, která není Nostr zapem + Přijato %1$s sats + Nové platby Odpovědět Označit jako přečtené @@ -2380,6 +2450,7 @@ Příprava pozvánky… Soukromá Pouze na pozvání + ŽIVĚ Zadejte platnou URL relay (wss://…). Název Popis @@ -2444,6 +2515,12 @@ Relaye, na kterých jste Oblíbené relaye Zatím žádné zprávy + Přidáno do %1$s + %1$s vás přidal do tohoto kanálu na %2$s. Zobrazit ho ve Zprávách? + Někdo + Zobrazit + Ignorovat + Opustit Připojte se k této skupině pro odesílání zpráv. Tato skupina je pouze na pozvání — k přispívání potřebujete pozvánku. @@ -2470,6 +2547,12 @@ %1$d osoby, které sledujete %1$d osob, které sledujete + + %1$d skupina + %1$d skupiny + %1$d skupiny + %1$d skupin + Soukromé Pro Předmět @@ -3174,9 +3257,89 @@ %1$s vytvořil(a) kanál %2$s %1$s vytvořil(a) kanál %1$s aktualizoval(a) profil kanálu + (upraveno) + (rozdíl zkrácen) + Plátno + V tomto pracovním prostoru zatím nebylo sdíleno žádné plátno. + Upravit plátno + Uložit plátno + Plátno (Markdown) + Upravit + Úprava zprávy + Upravit + (upraveno) + Úprava zprávy + %1$s píše… + %1$s a %2$s píší… + Několik lidí píše… + Pracovní prostory Buzz + Kde lidé a agenti tvoří společně + Opustit pracovní prostor + Vaše pracovní prostory + Zatím žádné pracovní prostory + Pracovní prostory Buzz jsou relaye, kde lidé a AI agenti tvoří společně. Připojte se k některému a uvidíte ho tady. + Procházet skupiny + Konzole agenta + Náklady · Persony · Živý pozorovatel + Nové téma fóra + O čem chcete diskutovat? + Odeslat téma + Vlákno fóra + Zatím žádné odpovědi. Buďte první, kdo odpoví. + Přímé zprávy + Zprávy Buzz + Soukromé konverzace ve vašich pracovních prostorech + Nová zpráva + Zatím žádné přímé zprávy + Začněte soukromou konverzaci s kýmkoli v pracovním prostoru Buzz. + Více + Skrýt konverzaci + Přidat člena + Přidat někoho do této konverzace + Neplatný npub nebo hex klíč + Jen vy + Pracovní prostor + Komu + Přidat někoho (npub nebo hex) + Zahájit konverzaci + Otevírání… + Odebrat + Pozvánka do pracovního prostoru + Připojit se k tomuto pracovnímu prostoru + Pracovní prostor + Pracovní prostor se otevře v zabezpečeném prohlížeči v aplikaci, kde si projdete podmínky a dokončíte připojení. Přihlásí vás vaším klíčem Amethyst — bez hesla. + Pokračovat v prohlížeči + Toto nevypadá jako platný odkaz s pozvánkou Buzz. + Platnost této pozvánky vypršela. Požádejte o novou. + Dokončete přijetí podmínek v prohlížeči a pak se vraťte — váš pracovní prostor tu na vás bude čekat. + Otevřít mé pracovní prostory + Importovat kanály pracovního prostoru + Hledání vašich kanálů… + Vaše kanály v tomto pracovním prostoru + Přidat + Přidat vše + Přidáno + Nenalezeny žádné kanály + Zdá se, že zatím nejste členem žádného kanálu na tomto relayi. Nejprve přijměte pozvánku do pracovního prostoru v prohlížeči a pak to zkuste znovu. Kanály Fóra + Pracuji… Přidat člena + Přidat lidi do tohoto pracovního prostoru + Vytvořit odkaz s pozvánkou + Odkaz s pozvánkou + Sdílet + Kopírovat + Pozvánku se nepodařilo vytvořit + Připnout kanál + Odepnout kanál + Zatím žádné konverzace + + Zobrazit další %1$d konverzaci + Zobrazit všechny %1$d konverzace + Zobrazit všech %1$d konverzace + Zobrazit všech %1$d konverzací + Doručení zprávy Zavřít Čekání, až relay přijme tuto zprávu diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index e331704c28..3d681f691f 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -32,6 +32,7 @@ Wird entschlüsselt… + Du: %1$s Gruppenbild Anstößiger Inhalt Relay-Hinweis @@ -344,6 +345,14 @@ %1$d Kanal %1$d Kanäle + + %1$d Workspace + %1$d Workspaces + + + %1$d Kanal + %1$d Kanäle + %1$d Mitglied %1$d Mitglieder @@ -400,6 +409,7 @@ Thread + Bild senden %1$d Antwort @@ -1252,6 +1262,7 @@ Space starten Für später planen Startzeit wählen + Agent-Konsole Nest-Server Wähle, an welche MoQ-Hostserver Amethyst deine Nests veröffentlicht. Der erste Eintrag wird standardmäßig verwendet, wenn du einen neuen Space startest. Deine Server @@ -1594,6 +1605,10 @@ Blossom-Synchronisierung Zeigt den Fortschritt beim Kopieren deiner Dateien über deine Blossom-Server hinweg. Keine gespeicherten Dateien auf deinen Blossom-Servern gefunden. + Gespeichert auf + Dateidetails + Auf allen Servern + Nicht auf allen Servern Auf fehlende spiegeln Löschen von… Löschen von %1$s @@ -1611,6 +1626,33 @@ Blob melden Grund (optional) Senden + Weitere Aktionen + Dateien importieren… + Dateien importieren + Durchsuche andere Blossom-Server nach Dateien, die du anderswo hochgeladen hast, und kopiere sie auf deine eigenen Server. + Zu prüfende Server + Alle aktivieren + Alle deaktivieren + Oder füge eine Serveradresse ein + Server prüfen + Wird geprüft… + Dieser Server ist nicht erreichbar + Auf den ausgewählten Servern wurden keine neuen Dateien gefunden. + Füge zuerst eigene Blossom-Server hinzu, damit die importierten Dateien irgendwohin kopiert werden können. + Meine Server verwalten + Eine Dateisynchronisierung läuft bereits. Versuche den Import erneut, sobald sie abgeschlossen ist. + + %1$d Datei + %1$d Dateien + + + %1$d Datei zum Import auf deine Server gefunden. + %1$d Dateien zum Import auf deine Server gefunden. + + + %1$d Datei importieren + %1$d Dateien importieren + Empfohlene Medienserver Amethysts Standardliste. Du kannst einzelne Server hinzufügen oder die gesamte Liste auf einmal übernehmen. Standardliste verwenden @@ -1639,6 +1681,20 @@ Keine App gefunden, die diese Zahlung verarbeiten kann. Bitte installiere eine kompatible Wallet. Es ist keine App installiert, die Zahlungen vom Typ %1$s verarbeiten kann. Bitte installiere eine kompatible Wallet. Zahlung konnte nicht geöffnet werden + BOLT12-Angebote + Veröffentliche wiederverwendbare BOLT12-Angebote, damit andere dich über Lightning zappen können, ohne jedes Mal eine eigene Rechnung. + Füge ein oder mehrere BOLT12-Angebote (lno1…) hinzu. Sie werden öffentlich geteilt, sodass dich jeder bezahlen kann. + Keine BOLT12-Angebote festgelegt. Füge unten eines hinzu ↓ + BOLT12-Angebot (lno1…) + Kein gültiges BOLT12-Angebot + BOLT12-Angebot löschen + Mit verbundener Wallet bezahlen + Betrag (sats) + BOLT12-Zahlung gesendet + BOLT12-Zahlung fehlgeschlagen: %1$s + BOLT12-Zap-Fehler + Über BOLT12 bezahlt, aber die Wallet hat keinen Nachweis zurückgegeben, daher wurde keine Zap-Quittung veröffentlicht. + Über BOLT12 bezahlt, aber der Zahlungsnachweis konnte nicht validiert werden, daher wurde keine Zap-Quittung veröffentlicht. Noch nicht angefangen Komprimieren Hochladen @@ -1745,6 +1801,10 @@ %1$s Sats Von %1$s für %1$s + Erhaltene Zahlungen + Benachrichtigt dich, wenn deine verbundene Wallet eine Zahlung erhält, die kein Nostr-Zap ist + %1$s sats erhalten + Neue Zahlungen Antworten Als gelesen markieren Ich @@ -2368,6 +2428,12 @@ Relays, auf denen du bist Beliebte Relays Noch keine Nachrichten + Zu %1$s hinzugefügt + %1$s hat dich auf %2$s zu diesem Kanal hinzugefügt. In Nachrichten anzeigen? + Jemand + Anzeigen + Ignorieren + Verlassen Tritt dieser Gruppe bei, um Nachrichten zu senden. Diese Gruppe ist nur mit Einladung — du brauchst eine Einladung, um zu posten. @@ -2386,6 +2452,10 @@ %1$d Person, der du folgst %1$d Personen, denen du folgst + + %1$d Gruppe + %1$d Gruppen + Privat An Betreff @@ -3070,9 +3140,84 @@ %1$s hat den Kanal %2$s erstellt %1$s hat den Kanal erstellt %1$s hat das Kanalprofil aktualisiert + (bearbeitet) + (Diff gekürzt) + In diesem Workspace wurde noch kein Canvas geteilt. + Canvas bearbeiten + Canvas speichern + Bearbeiten + Nachricht bearbeiten + Bearbeiten + (bearbeitet) + Nachricht bearbeiten + %1$s tippt… + %1$s und %2$s tippen… + Mehrere Personen tippen… + Buzz-Workspaces + Wo Menschen und Agenten gemeinsam bauen + Workspace verlassen + Deine Workspaces + Noch keine Workspaces + Buzz-Workspaces sind Relays, in denen Menschen und KI-Agenten gemeinsam bauen. Tritt einem bei, um ihn hier zu sehen. + Gruppen durchsuchen + Agent-Konsole + Kosten · Personas · Live-Beobachter + Neues Forumsthema + Worüber möchtest du diskutieren? + Thema veröffentlichen + Forum-Thread + Noch keine Antworten. Sei die erste Person, die antwortet. + Direktnachrichten + Buzz-DMs + Private Unterhaltungen in deinen Workspaces + Neue Nachricht + Noch keine Direktnachrichten + Beginne eine private Unterhaltung mit jemandem in einem Buzz-Workspace. + Mehr + Unterhaltung ausblenden + Mitglied hinzufügen + Jemanden zu dieser DM hinzufügen + Kein gültiger npub- oder Hex-Schlüssel + Nur du + An + Jemanden hinzufügen (npub oder hex) + Unterhaltung beginnen + Wird geöffnet… + Entfernen + Workspace-Einladung + Diesem Workspace beitreten + Rolle + Du öffnest den Workspace in einem sicheren In-App-Browser, um die Bedingungen zu prüfen und den Beitritt abzuschließen. Die Anmeldung erfolgt mit deinem Amethyst-Schlüssel — ohne Passwort. + Im Browser fortfahren + Das sieht nicht nach einem gültigen Buzz-Einladungslink aus. + Diese Einladung ist abgelaufen. Bitte um eine neue. + Schließe die Zustimmung zu den Bedingungen im Browser ab und komm dann zurück — dein Workspace wartet auf dich. + Meine Workspaces öffnen + Workspace-Kanäle importieren + Deine Kanäle werden gesucht… + Deine Kanäle in diesem Workspace + Hinzufügen + Alle hinzufügen + Hinzugefügt + Keine Kanäle gefunden + Du scheinst noch in keinem Kanal auf diesem Relay Mitglied zu sein. Nimm zuerst die Workspace-Einladung im Browser an und versuche es dann erneut. Kanäle Foren + Arbeitet… Mitglied hinzufügen + Personen zu diesem Workspace hinzufügen + Einladungslink erstellen + Einladungslink + Teilen + Kopieren + Einladung konnte nicht erstellt werden + Kanal anheften + Kanal nicht mehr anheften + Noch keine Unterhaltungen + + %1$d weitere Unterhaltung anzeigen + Alle %1$d Unterhaltungen anzeigen + Nachrichtenzustellung Schließen Warte darauf, dass ein Relay diese Nachricht akzeptiert diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index a23fb3ef99..be0eb38a2f 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -2367,6 +2367,8 @@ आमन्त्रण उत्पादन… निजी केवल आमन्त्रित + वर्तमान + %1$d से अधिक प्रविष्ट करें एक मान्य पुनःप्रसारक पता (wss://…)। नाम विवरण @@ -2449,6 +2451,10 @@ %1$d व्यक्ति जिसके आप अनुचर %1$d व्यक्ति जिसके आप अनुचर + + %1$d समूह + %1$d समूह + निजी के लिए विषय diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index a3a73e4124..672de72032 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -737,7 +737,7 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Nie otrzymałeś jeszcze żadnych odznak. Aplikacje · %1$d Aplikacje - Poleć aplikacjom Nostr, których używasz, aby inni mogli je odkryć. + Poleć aplikacje Nostr, z których korzystasz, aby inni mogli je odkryć. Polecane aplikacje Wybierz aplikacje Nostr, które chcesz publicznie polecić. Twoje rekomendacje pojawią się na Twoim profilu i pomogą innym użytkownikom znaleźć aplikacje z treściami, których ten klient nie może otworzyć. Nie znaleziono jeszcze żadnych aplikacji. Aplikacje będą się tu pojawiać w miarę ich wykrywania na Twoich transmiterach. @@ -2451,6 +2451,8 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Przygotowywanie zaproszenia… Prywatna Tylko na zaproszenie + NA ŻYWO + %1$d+ Wprowadź poprawny adres URL transmitera (wss://…). Nazwa Opis @@ -2541,6 +2543,12 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest %1$d osób, które obserwujesz %1$d osoby, które obserwujesz + + %1$d grupa + %1$d grup + %1$d grup + %1$d grupy + Prywatna Do Temat @@ -4388,6 +4396,7 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Wyślij bez PoW Odrzuć post ◆ %1$s na posta na tym urządzeniu + <1 s %1$d sekundę %1$d sekund diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 3e68c3236f..8f93db05ac 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -32,6 +32,7 @@ Descriptografando… + Você: %1$s Imagem do grupo Conteúdo explícito Aviso do relay @@ -344,6 +345,14 @@ %1$d canal %1$d canais + + %1$d espaço de trabalho + %1$d espaços de trabalho + + + %1$d canal + %1$d canais + %1$d membro %1$d membros @@ -400,6 +409,7 @@ Tópico + Enviar imagem %1$d resposta @@ -1250,6 +1260,7 @@ Iniciar espaço Agendar para depois Escolha um horário de início + Console do agente Servidores de nest Escolha em quais servidores MoQ o Amethyst publica seus nests. A primeira entrada é usada por padrão ao iniciar um novo espaço. Seus servidores @@ -1592,6 +1603,10 @@ Sincronização Blossom Mostra o progresso enquanto seus arquivos são copiados entre seus servidores Blossom. Nenhum arquivo armazenado encontrado nos seus servidores Blossom. + Armazenado em + Detalhes do arquivo + Em todos os servidores + Não está em todos os servidores Espelhar para os ausentes Excluir de… Excluir de %1$s @@ -1609,6 +1624,33 @@ Denunciar blob Motivo (opcional) Enviar + Mais ações + Importar arquivos… + Importar arquivos + Procure em outros servidores Blossom por arquivos que você enviou em outro lugar e copie-os para os seus próprios servidores. + Servidores a verificar + Ativar todos + Desativar todos + Ou cole um endereço de servidor + Verificar servidores + Verificando… + Não foi possível acessar este servidor + Nenhum arquivo novo foi encontrado nos servidores selecionados. + Adicione primeiro seus próprios servidores Blossom, para que haja onde copiar os arquivos importados. + Gerenciar meus servidores + Uma sincronização de arquivos já está em andamento. Tente importar novamente quando ela terminar. + + %1$d arquivo + %1$d arquivos + + + Encontrado %1$d arquivo para importar para os seus servidores. + Encontrados %1$d arquivos para importar para os seus servidores. + + + Importar %1$d arquivo + Importar %1$d arquivos + Servidores de Mídia Recomendados Lista padrão do Amethyst. Você pode adicioná-los individualmente ou adicionar a lista. Usar Lista Padrão @@ -1637,6 +1679,20 @@ Nenhum aplicativo encontrado para processar este pagamento. Por favor, instale uma carteira compatível. Nenhum app instalado para processar pagamentos %1$s. Instale uma carteira compatível. Não foi possível abrir o pagamento + Ofertas BOLT12 + Publique ofertas BOLT12 reutilizáveis para que outras pessoas possam zapear você pela Lightning sem uma fatura separada a cada vez. + Adicione uma ou mais ofertas BOLT12 (lno1…). Elas são compartilhadas publicamente, então qualquer pessoa pode pagar você. + Nenhuma oferta BOLT12 definida. Adicione uma abaixo ↓ + Oferta BOLT12 (lno1…) + Não é uma oferta BOLT12 válida + Excluir oferta BOLT12 + Pagar com a carteira conectada + Valor (sats) + Pagamento BOLT12 enviado + Falha no pagamento BOLT12: %1$s + Erro no zap BOLT12 + Pago via BOLT12, mas a carteira não retornou nenhum comprovante, então nenhum recibo de zap foi publicado. + Pago via BOLT12, mas o comprovante de pagamento não foi validado, então nenhum recibo de zap foi publicado. Não iniciado Compactando Enviando @@ -1743,6 +1799,10 @@ %1$s sats De %1$s por %1$s + Pagamentos recebidos + Notifica você quando sua carteira conectada recebe um pagamento que não é um zap do Nostr + Recebidos %1$s sats + Novos pagamentos Responder Marcar como lida Eu @@ -1776,6 +1836,7 @@ Desenvolvedor Jogos + Repostagens Notifica você quando alguém reposta sua publicação %1$s repostou sua publicação Novos reposts @@ -2302,6 +2363,7 @@ Preparando convite… Privado Somente por convite + AO VIVO Insira uma URL de relay válida (wss://…). Nome Descrição @@ -2366,6 +2428,12 @@ Relays em que você está Relays populares Nenhuma mensagem ainda + Adicionado a %1$s + %1$s adicionou você a este canal em %2$s. Mostrar em Mensagens? + Alguém + Mostrar + Ignorar + Sair Entre neste grupo para enviar mensagens. Este grupo é somente por convite — você precisa de um convite para publicar. @@ -2384,6 +2452,10 @@ %1$d pessoa que você segue %1$d pessoas que você segue + + %1$d grupo + %1$d grupos + Privado Para Assunto @@ -3068,9 +3140,86 @@ %1$s criou o canal %2$s %1$s criou o canal %1$s atualizou o perfil do canal + (editado) + (diff truncado) + Nenhum canvas foi compartilhado neste espaço de trabalho ainda. + Editar canvas + Salvar canvas + Editar + Editando mensagem + Editar + (editado) + Editando mensagem + %1$s está digitando… + %1$s e %2$s estão digitando… + Várias pessoas estão digitando… + Espaços de trabalho Buzz + Onde humanos e agentes constroem juntos + Sair do espaço de trabalho + Seus espaços de trabalho + Nenhum espaço de trabalho ainda + Espaços de trabalho Buzz são relays onde humanos e agentes de IA constroem juntos. Entre em um para vê-lo aqui. + Explorar grupos + Console do agente + Custos · Personas · Observador ao vivo + Novo tópico do fórum + O que você quer discutir? + Publicar tópico + Discussão do fórum + Nenhuma resposta ainda. Seja a primeira pessoa a responder. + Mensagens diretas + DMs do Buzz + Conversas privadas nos seus espaços de trabalho + Nova mensagem + Nenhuma mensagem direta ainda + Inicie uma conversa privada com qualquer pessoa em um espaço de trabalho Buzz. + Mais + Ocultar conversa + Adicionar membro + Adicionar alguém a esta DM + Não é uma chave npub ou hex válida + Só você + Espaço de trabalho + Para + Adicionar alguém (npub ou hex) + Iniciar conversa + Abrindo… + Remover + Convite para espaço de trabalho + Entrar neste espaço de trabalho + Espaço de trabalho + Função + Você abrirá o espaço de trabalho em um navegador seguro dentro do app para revisar os termos e concluir a entrada. Ele faz seu login com sua chave do Amethyst — sem senha. + Continuar no navegador + Isso não parece ser um link de convite válido do Buzz. + Este convite expirou. Peça um novo. + Termine de aceitar os termos no navegador e depois volte — seu espaço de trabalho estará esperando. + Abrir meus espaços de trabalho + Importar canais do espaço de trabalho + Procurando seus canais… + Seus canais neste espaço de trabalho + Adicionar + Adicionar todos + Adicionado + Nenhum canal encontrado + Você ainda não parece ser membro de nenhum canal neste relay. Aceite primeiro o convite do espaço de trabalho no navegador e tente novamente. Canais Fóruns + Trabalhando… Adicionar membro + Adicionar pessoas a este espaço de trabalho + Criar link de convite + Link de convite + Compartilhar + Copiar + Não foi possível criar o convite + Fixar canal + Desafixar canal + Nenhuma conversa ainda + + Ver mais %1$d conversa + Ver todas as %1$d conversas + Entrega da mensagem Fechar Aguardando um relay aceitar esta mensagem diff --git a/amethyst/src/main/res/values-sl-rSI/strings.xml b/amethyst/src/main/res/values-sl-rSI/strings.xml index 4c5f3fbda1..6a388b3ae1 100644 --- a/amethyst/src/main/res/values-sl-rSI/strings.xml +++ b/amethyst/src/main/res/values-sl-rSI/strings.xml @@ -26,13 +26,21 @@ +%1$d\nodgovorov +%1$d\nodgovorov + + %1$d oseba, ki ji sledite, je prijavila + %1$d osebi, ki jima sledite, sta se prijavili + %1$d osebe, ki jim sledite, so se prijavile + %1$d oseb, ki ji sledite, so se prijavile + Dogodek se nalaga ali pa ni najden v tvojih relejih 👀 Slika kanala Referenčni dogodek ni najden Dešifriranje sporočila ni uspelo + Dekodiranje… + Ti: %1$s Slika skupine Eksplicitna vsebina Obvestilo releja @@ -314,7 +322,19 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Že imam Nostr račun? Nalagam vir vsebin Račun se nalaga + Prevzem povabila… + Povabila ni mogoče pridobiti. Povezava je morda potekla ali pa so njeni releji nedosegljivi. + Ta povezava do povabila ni veljavna ali pa je ni mogoče odpreti s tem računom. + Te povezave do povabila ni mogoče odpreti. Morda je zastarela, jo je nadomestila nova ali pa je bila ustvarjena z novejšo različico aplikacije. Zaprosite za novo povezavo do povabila. + Ta povezava do povabila je bila preklicana in je ni več mogoče uporabiti. Zaprosite za novo. + Ta povezava do povabila je potekla in je ni več mogoče uporabiti. Zaprosite za novo povezavo. + Ime skupnosti je razkrito šele, ko se ji pridružite. + Pridružitev se poveže z releji tega povabila, objavi obvestilo o pridružitvi, podpisano z vašim računom, in doda skupnost na vaš seznam. Dokler ne tapnete »Pridruži se«, se ne pošlje ničesar. + Releji, s katerimi se bo povezalo to povabilo: %1$s + Kanali Concord + Pridružili se niste še nobenemu kanalu Concord. Ustvarite ga ali odprite povezavo do povabila. Še ni kanalov. + Prikaži vse kanale Pošlji sliko Odpri kanal Dodaj pasico @@ -324,10 +344,45 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Ime kanala Izbriši kanal Izbrišem kanal? + Želite izbrisati kanal #%1$s? Tega ni mogoče razveljaviti, kanala pa tudi ni mogoče ponovno ustvariti z istim ID-jem. Izbriši + Zapusti skupnost + Zapustim skupnost? + Želite zapustiti skupnost %1$s? Odstranjena bo iz seznama tega računa in sinhronizacija v vaših napravah se bo ustavila. Skupnost o tem ne bo obveščena in ne boste odstranjeni iz seznama njenih članov. Sporočil, ki jih ne boste mogli več dešifrirati, morda ne bo mogoče obnoviti, vrnete pa se lahko le z novim povabilom. + To skupnost ste ustvarili vi. Z izhodom je ne boste izbrisali ali predali komu drugemu, vendar boste zavrgli lastniški ključ, shranjen na vašem seznamu – tako je ne boste mogli več upravljati. + Kjer so objavljeni in prebrani šifrirani ravninski sloji (encrypted planes) te skupnosti + %1$s tipka… + %1$s in %2$s tipkata… + Več ljudi tipka… + Nov kanal Concord Ime Opis (neobvezno) Releji + URL ikone (izbirno) + + %1$d kanal + %1$d kanala + %1$d kanali + %1$d kanalov + + + %1$d delovni prostor + %1$d delovna prostora + %1$d delovni prostori + %1$d delovnih prostorov + + + %1$d kanal + %1$d kanala + %1$d kanali + %1$d kanalov + + + %1$d član + %1$d člana + %1$d člani + %1$d članov + %1$d novo sporočilo %1$d nova sporočila @@ -337,32 +392,52 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Še ni sporočil Ustvari Povabi ljudi + Povezava za povabilo Povišaj v administratorja Odstrani administratorja Izključi Izključim iz te skupnosti? + Ta član bo dodan na seznam prepovedi skupnosti. Vsi člani bodo skrili njegova sporočila in zavrgli njegove prihodnje objave. Pozneje ga lahko ponovno omogočite. Nastavi ikono skupnosti + Releji, ki shranjujejo šifrirana sporočila te skupnosti. Pustite prazno, če želite uporabiti svoje. Uredi skupnost Shrani Člani + Za zdaj ni lastnika, skrbnikov ali blokiranih članov za prikaz. Povišaj v administratorja + Odstrani skrbnika Blokiraj Odstrani blokado Odstrani iz skupnosti Odstranim člana? + S tem se zamenja šifrirni ključ skupnosti, tako da ta član ne more več brati ničesar, kar bo poslano kasneje. Vsem ostalim se ključ samodejno posodobi. Tega ni mogoče razveljaviti. Odstrani + Vloge… + Dodeli vloge + Izberite vse vloge, ki jih mora imeti ta član. Odznačitev vloge jo odstrani. + Shrani + Nimate višjega položaja kot ta član + Ni vlog, ki bi jih lahko dodelili + Vloge tega člana ni bilo mogoče posodobiti. Lastnik Administrator Blokiran Pridruži se skupnosti Povabilo Concord skupnosti + Povabilo v Concord (odprite celotno povezavo za povabilo, da se pridružite) Concord + Prikaz skupnosti Concord + Vrstično Po skupnosti + Prikaži vsak kanal kot lasten pogovor, vmešan med vaše klepete. + Zložite kanale posamezne skupnosti v eno vrstico, ki je postavljena na njeno najnovejše sporočilo. - v pogovoru + V klepetu + V nizu objav Niz objav + Pošlji sliko %1$d odgovor @@ -377,7 +452,13 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Ne nejdem tega sporočila Preiskani vsi releji · dotakni se za ogled + Kdo je dal prijavo? + Opusti + Skrij + Oseba, ki jo spremljate, je opozorila na ta račun. + Prijavljeno + +%1$d "Napaka pri nalaganju odgovorov: " Poskusi ponovno Ni še obvestil. @@ -397,6 +478,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem So odlične za odprte skupnosti, povezane s specifičnimi temami. Nekatere od teh skupin so kratkotrajne zato sporočila v klepetu sčasoma izginejo Javni pogovor + Ustvari nov javen klepet MLS skupina Še ni sporočil Metapodatki Javnega Klepeta @@ -474,6 +556,15 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Deli URL slike Ustvarjam predogled… Deljeno z Amethyst + Deli kot QR kodo + Spletna povezava + Nostr povezava + Skeniraj s katerokoli kamero na telefonu + Skeniraj z Nostr aplikacijo + Fotografija + Koda QR s spletno povezavo do te opombe + Koda QR s povezavo Nostr do te opombe + Miniatura je skrita zaradi občutljive vsebine Avtorjev ID ID zapiska Kopiraj tekst @@ -809,6 +900,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Ta nApplet želi uporabiti svojo zasebno shrambo. Ta nApplet želi plačati Lightning račun. + ⚠ Ta nApplet želi plačati račun Lightning, ki ne določa zneska — prejemnik plačila določi, koliko se odtrga. To dovolite samo, če mu zaupate. Ta nApplet želi pridobiti spletni vir. Ta nApplet želi naložiti datoteko na vaš medijski strežnik. Ta nApplet vam želi prikazati obvestila. @@ -823,11 +915,74 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem kind-only summary would hide what is actually being signed. These replaceable lists are already cached on the account, so the dialog diffs the proposed list against the current one and reports what actually changes rather than a raw total. --> + + Sledi %1$d novemu računu + Sledi %1$d novima računoma + Sledi %1$d novim računom + Sledi %1$d novim računom + + + PRENEHA SLEDITI %1$d računu + PRENEHA SLEDITI %1$d računoma + PRENEHA SLEDITI %1$d računom + PRENEHA SLEDITI %1$d računom + + + doda %1$d rele + doda %1$d releja + doda %1$d releje + doda %1$d relejev + + + ODSTRANI %1$d rele + ODSTRANI %1$d releja + ODSTRANI %1$d releje + ODSTRANI %1$d relejev + + + utiša %1$d dodatno osebo + utiša %1$d dodatni osebi + utiša %1$d dodatne osebe + utiša %1$d dodatnih oseb + + + PREKINE UTIŠANJE %1$d osebe + PREKINE UTIŠANJE %1$d oseb + PREKINE UTIŠANJE %1$d oseb + PREKINE UTIŠANJE %1$d oseb + + ⚠ To sledi uporabniku %1$s. + ⚠ To PRENEHA SLEDITI uporabniku %1$s. + ⚠ To utiša uporabnika %1$s. + ⚠ To PREKINE UTIŠANJE uporabnika %1$s. + ⚠ To prepiše vaš seznam sledilcev: %1$s. + ⚠ To prepiše vaš seznam relejev: %1$s. Vaše objave in branja se premaknejo skupaj z njim. + ⚠ To prepiše vaš seznam utišanih: %1$s. Utišane besede in oznake niso prikazane tukaj — za celoten seznam tapnite »Pokaži dogodek« + %1$s in %2$s + To ponovno objavi vaš obstoječi seznam nespremenjen. + + To zapisuje seznam z %1$d vnosom. Amethyst nima zajete kopije za primerjavo. + ⚠ To zapisuje seznam z %1$d vnosoma. Amethyst nima zajete kopije za primerjavo. + ⚠ To zapisuje seznam z %1$d vnosi. Amethyst nima zajete kopije za primerjavo. + ⚠ To zapisuje seznam z %1$d vnosi. Amethyst nima zajete kopije za primerjavo. + + + ⚠ To zahteva izbris %1$d vašega dogodka. + ⚠ To zahteva izbris %1$d vaših dogodkov. + ⚠ To zahteva izbris %1$d vaših dogodkov. + ⚠ To zahteva izbris %1$d vaših dogodkov. + + + Vsebuje %1$d oznako. Tapnite »Pokaži dogodek«, da vidite, kaj bo podpisano. + Vsebuje %1$d oznaki. Tapnite »Pokaži dogodek«, da vidite, kaj bo podpisano. + Vsebuje %1$d oznake. Tapnite »Pokaži dogodek«, da vidite, kaj bo podpisano. + Vsebuje %1$d oznak. Tapnite »Pokaži dogodek«, da vidite, kaj bo podpisano. + Povezava z Nostrom se želi povezati z vašim nostr računom @@ -864,9 +1019,13 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem berem tvoja zasebna sporočila + preberite zasebna sporočila z %1$s + Vedno dovoli za %1$s + Amethyst tega sporočila ni mogel dešifrirati. Morda ni naslovljeno na ta račun. + Sporočila z Povezane aplikacije Prekliči vsa dovoljenja @@ -878,6 +1037,103 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Zavrni Aplikacije, dovoljenja, podpisovalnik, napplet, nsite, spletna aplikacije zaupnik povezan. + Oddaljen podpisnik + Podpisnik, bunker, NIP-46, Nostr Connect, oddaljeno podpisovanje + Dovolite drugim aplikacijam podpisovanje z vašim ključem. Amethyst posluša na vaših prejetih relejih in pred podpisom preveri dovoljenja posamezne aplikacije — pri čemer uporablja enake ravni zaupanja kot Povezane aplikacije. + Deluj kot oddaljeni podpisnik + + Poslušanje na %1$d releju + Poslušanje na %1$d relejih + Poslušanje na %1$d relejih + Poslušanje na %1$d relejih + + Ni nastavljenih vhodnih relejev. Dodajte vhodne releje, da lahko aplikacije dosežejo vašega podpisnika. + + Poslušanje na %1$d vhodnem releju + Poslušanje na %1$d vhodnih relejih, oba poveza + Poslušanje na %1$d vhodnih relejih, vsi povezani + Poslušanje na %1$d vhodnih relejih, vsi povezani + + Povezan na %1$d od \%2$d relejev + Vaš bunker naslov + Prilepite to v drugo aplikacijo, da jo povežete s svojim ključem. + Kopiraj + Naslov bunkerja kopiran + Nov naslov + Generiran nov bunker naslov + Ustvarim nov naslov? + S tem ustvarite nov bunker naslov in prekinete povezavo z vsemi trenutno povezanimi aplikacijami. Nihče s starim naslovom – vključno s pošiljatelji neželene pošte – vas ne more več doseči. Svoje aplikacije znova povežite s skeniranjem nove kode. + Generiraj + Prekliči + Poveži aplikacijo + prilepi nostrconnect:// povezavo + Poveži + Upravljanje povezanih aplikacij + Z vašim podpisnikom še ni povezana nobena aplikacija.\n\nZa povezavo skenirajte ali prilepite kodo aplikacije. Povezane aplikacije se prikažejo tukaj, neaktivne pa se po enem tednu samodejno odstranijo. + uporabljeno %1$s + Povezano + Brez povezave + Ponovno poveži + Ponovno povezovanje z releji… + Releji + Podpisuje preko vaših vhodnih relejev — ta aplikacija ni dodala svojih, zato ne ohranja dodatne povezave v ozadju. + Amethyst ohranja povezavo v ozadju z vsakim od njih, dokler ta aplikacija ostaja povezana. Pozabite aplikacijo, da jih prekinete. + + %1$d rele + %1$d releja + %1$d releji + %1$d relejev + + Aplikacija je povezana. Amethyst zdaj zanjo podpisuje v ozadju. + Povezano %1$s. Amethyst zdaj za to podpisuje v ozadju. + Ni veljavna povezava nostrconnect:// + Ta povezava ne vsebuje nobenega releja za povezavo + Ni se bilo mogoče povezati: %1$s + Povezava je bila zavrnjena + Ta aplikacija zahteva: + To je račun samo za branje in ne more podpisovati. + Aplikacija oddaljenega podpisnika + Amethyst ohranja povezavo v ozadju (prikazano kot trajno obvestilo), da lahko odgovarja na zahteve za podpisovanje, ko je zaprt. + Podpisovanje za druge aplikacije + Vklopite podpisnika + V živo + + %1$d zahteva za podpis + %1$d zahtevi za podpis + %1$d zahteve za podpis + %1$d zahtev za podpis + + Izberi vse + Nič ne izberi + Zapomni si te za vsako aplikacijo + kot %1$s + Dovoli %1$d + Zavrni %1$d + Nedavna aktivnost + Še ni bilo obdelanih zahtev. + zavrnjeno + Podpisan dogodek (tip(kind) %1$d) + Šifrirano sporočilo + Dešifrirano sporočilo + Deljen vaš javni ključ + Povezan + Ping + Prikazan seznam relejev + %1$s + Zahteve za podpisovanje + Celozaslonski pozivi, ki vas prosijo za odobritev aplikacije, ki želi, da Amethyst z vašim ključem podpisuje, šifrira ali dešifrira. + Želite odobriti zahtevo za podpisovanje? + Aplikacija se želi povezati + Tapnite za preveritev + Skenirajte za povezavo aplikacije z vašim ključem + Skeniraj kodo + Raje prilepite povezavo + + %1$d povezana aplikacija + %1$d povezani aplikaciji + %1$d povezane aplikacije + %1$d povezanih aplikacij + Podpisujem stopnjo zaupanja Zmogljivosti Pozabi to aplikacijo @@ -948,6 +1204,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Skrij starejše različice Kratki posnetki Javni klepeti + Lokacijski kanali Paketi sledenih Posnetki v živo Zvočne sobe @@ -1078,6 +1335,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Začni sobo za klepet Načrtovano za kasneje Izberi začetni čas + Konzola za agente Strežniki gnezda Izberite strežnike MoQ, na katerih Amethyst objavlja vaša gnezda. Prvi vnos bo privzeto uporabljen ob začetku novega pogovora. Vaši strežniki @@ -1302,6 +1560,8 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem že v tem javnem zaznamku je zasebni zaznamek tukaj Zasebno — vidno le označenim sodelujočim + Zasebni zapisek + To sporočilo je bilo dostavljeno zasebno in ni podpisano. Vidijo ga lahko le označeni udeleženci, nihče zunaj pa ne more dokazati, kdo ga je napisal. Nastavi kot zasebno: šifriraj zapis le za obveščene uporabnike Nastavi kot javno Odgovori na zasebni zapis so vedno zasebni. @@ -1309,6 +1569,8 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Ni še prejemnikov: ta zapis lahko vidiš samo ti. Dodaj ljudi, da ga deliš z njimi. Dodaj Odstrani uporabnika iz obvestil + Pošiljanje obvestil. Tapnite, da utišate obvestila za tega uporabnika + Pošiljanje obvestil. Tapnite, da utišate obvestila za tega uporabnika Poišči in dodaj prejemnika obvestil Ni v tem zaznamku Odstrani zaznamek iz seznama @@ -1408,11 +1670,96 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem V predpomnilnik shrani le slike profilov Lokalni predpomnilnik omeji le na slike profilov. Slike in videoposnetki iz vira vsebin se bodo prenašali neposredno z izvornih strežnikov. Nimate nastavljenih Blossom strežnikov. Uporabite lahko Amethyst-ov seznam ali dodajte enega spodaj ↓ + Vedenje ob nalaganju + Zrcaljenje naloženih datotek + Po nalaganju datoteko kopirajte na svoje druge strežnike Blossom, da ostane na voljo, če se eden izklopi. + Optimiziraj medije na strežniku + Naložite prek strežniške končne točke /media, da lahko odstrani metapodatke in stisne datoteko. Shranjena datoteka se lahko razlikuje od izvirnika. + Upravljanje shranjenih datotek + Moje Blossom datoteke + Osveži + Sinhroniziraj vse + Nekatere vaše datoteke še niso na vseh vaših strežnikih. + Kopiranje vaših datotek med strežniki… + Sinhronizacija zaključena + Prekliči + Blossom sinhronizacija + Prikazuje napredek med kopiranjem vaših datotek med vašimi strežniki Blossom. + Na vaših strežnikih Blossom ni bilo najdenih shranjenih datotek. + Shranjeno na + Podrobnosti datoteke + Na vseh strežnikih + Ni na vseh strežnikih + Zrcali na manjkajoče + Izbriši iz… + Izbriši iz %1$s + Prijavi + Odpri + Ta strežnik za nalaganje zahteva plačilo: %1$s + Zahtevano plačilo + %1$s za shranjevanje te datoteke zahteva plačilo preko omrežja Lightning. Za nadaljevanje plačajte iz svoje povezane denarnice. + %1$s pove: \"%2$s\" + Plačaj + + Plačaj %1$d sat + Plačaj %1$d sata + Plačaj %1$d sate + Plačaj %1$d satov + + Prijavi blob + Razlog (izbirno) + Pošlji + Več možnosti + Uvozi datoteke… + Uvozi datoteke + Preveri druge strežnike Blossom za datoteke, ki si jih naložil drugje, in jih kopiraj na svoje strežnike. + Strežniki za preverjanje + Omogoči vse + Onemogoči vse + Ali pa prilepite naslov strežnika + Preveri stražnike + PReverjam… + Strežnika ni mogoče doseči + Na izbranih strežnikih ni bilo najdenih novih datotek. + Najprej dodajte svoje strežnike Blossom, da boste imeli kam kopirati uvožene datoteke. + Upravljanje mojih strežnikov + Sinhronizacija datotek že poteka. Poskusite znova, ko se zaključi. + + %1$d datoteka + %1$d datoteki + %1$d datoteke + %1$d datotek + + + Najdena %1$d datoteka za uvoz na vaše strežnike. + Najdeni %1$d datoteki za uvoz na vaše strežnike. + Najdene %1$d datoteke za uvoz na vaše strežnike. + Najdenih %1$d datotek za uvoz na vaše strežnike. + + + Uvozi %1$d datoteko + Uvozi %1$d datoteki + Uvozi %1$d datoteke + Uvozi %1$d datotek + Priporočljivi medijski strežniki Privzeti seznam Amethysta. Lahko jih dodate posamično ali dodate celoten seznam. Uporabi privzeti seznam Dodaj medijski strežnik Odstrani medijski strežnik + Podrsajte za spremembo vrstnega reda. Naložene datoteke se zaporedoma poskušajo poslati na strežnike od zgoraj navzdol. + Prednost nalaganja + Dodaj strežnik + Priporočeno + Ali prilepite naslov strežnika + Predpomnilnik naprave + Primarni + Dodano + Razvrsti strežnik + Povezano + Počasno + Brez povezave + Preverjam… Cilji plačil Objavite svoje naslove za plačila, da vam lahko drugi neposredno pošiljajo sredstva. Dodajte naslove za plačila za različna omrežja (npr. bitcoin, lightning, monero). @@ -1423,6 +1770,20 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Ni aplikacije za tovrstno plačilo. Namestite združljivo denarnico. Ni nameščene aplikacije za plačila %1$s. Namesti združljivo denarnico. Plačila ni mogoče odpreti + BOLT12 Ponudbe + Objavite večkrat uporabne ponudbe BOLT12, da vam lahko drugi pošiljajo zap-e preko omrežja Lightning brez ustvarjanja novega računa vsakič. + Dodajte eno ali več ponudb BOLT12 (lno1…). Objavljene so javno, tako da vam lahko plača kdo koli. + Ni nastavljenih ponudb BOLT12. Dodajte jo spodaj ↓ + BOLT12 ponudba (lno1…) + Neveljavna ponudba BOLT12 + Izbriši BOLT12 ponudbo + Plačaj s povezano denarnico + Znesek (sat) + BOLT12 plačilo poslano + BOLT12 plačilo ni uspelo: %1$s + BOLT12 zap napaka + Plačano preko BOLT12, vendar denarnica ni vrnila dokazila, zato potrdilo o zapu ni bilo objavljeno. + Plačano preko BOLT12, vendar dokazilo o plačilu ni prestalo preverjanja, zato potrdilo o zapu ni bilo objavljeno. Čakam na začetek Zgoščujem Nalagam @@ -1440,6 +1801,11 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Vsi sledeni sledenih Privzeti seznam sledenih V moji okolici + Teleportacija na lokacijo… + Sledi tej lokaciji + Prenehaj slediti tej lokaciji + Pošiljam v + Spremeni Globalno Izbrani Moje @@ -1524,8 +1890,15 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem %1$s sat Od %1$s za %1$s + Plačilo prejeto + Obvesti vas, ko vaša povezana denarnica prejme plačilo, ki ni Nostr zap + Prejeto %1$s sat + Nova plačila Odgovori Označi kot prebrano + Jaz + Novo sporočilo + Dodani ste bili v %1$s Novo sporočilo Novi zapi Odzivne ikone @@ -1548,13 +1921,48 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem %1$s vas je omenil Nove omembe + Sporočila + Družabno + Plačila + Vsebina + Razvijalec + Igre + Deljenja + Obvesti vas, ko nekdo znova objavi vašo objavo + Oseba %1$s je delila vašo objavo + Nove delitve + Medijske vsebine + Obvesti vas, ko vas nekdo označi na fotografiji ali v videoposnetku + Oseba %1$s je delila fotografijo + Oseba %1$s je delila video + Nova medijska vsebina + Članki in poudarki + Obvesti vas, ko vas nekdo emeni ali označi v članku + %1$s vas je omenil v članku + %1$s je poudaril vaš članek + Novi članki + Koda in Git + Obvesti vas o težavah, popravkih in zahtevkih za poteg (PR) + %1$s je odprl/a težavo + %1$s je poslal/a popravek + %1$s je odprl/a zahtevo za popravek (PR) + %1$s je osvežil/a zahtevo za popravek (PR) + Nove aktivnosti v kodi + Značke + Vas obvesti ko si prislužite značko + Prislužili ste si značko + Nagrajeni ste bili od %1$s + Nove značke + Nutzap od %1$s + Onchain zap od %1$s + %1$s sprašuje Dohodni klici Obvestila o dohodnih glasovnih in video klicih @@ -1620,6 +2028,13 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Povezovanje vhodnih relejev\u2026 Vedno aktivna obvestila Ohranja stalno povezavo z vašimi releji za takojšnjo dostavo obvestil. Prikazuje trajno obvestilo. Porabi več baterije, a zagotavlja, da ne zamudite nobenega sporočila. + Storitev obveščanja + Vklop + Izklop + Storitev obveščanja v ozadju + Glavno stikalo za sprotna obvestila v ozadju. Izklopite za varčevanje z baterijo — podobno kot način letenja. Ko je stikalo izklopljeno, noben račun v ozadju ne ostane povezan, nastavitev pa se ohrani (tudi po ponovnem zagonu), dokler je znova ne vklopite. + Aktivni računi v ozadju + Ta račun naj ostane aktiven v ozadju Razdeli opozorila glede na sledilce Prikaže dva zavihka z obvestili — Sledim (ljudje, ki jim slediš) in Vsi ostali. Indikator neprebranih obvestil sveti le za aktivnosti ljudi, ki jim slediš. Prikaži sporočilo @@ -1785,16 +2200,26 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Samo z WiFi Nikoli + Wi-Fi Celovit Poenostavljen Optimiziran + Polno Klasično Moderno Sistemska Svetla Temna Nastavitve aplikacije + Prikaz + Medijske vsebine in podatki + Splošno + Povezava + Usmeri preko Tor + Odseki in viri vsebin + Vidni zavihki + Opomniki Wallet Connect Jezik Tema @@ -1813,6 +2238,10 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Serif Monospace + Sistem + Sans + Serif + Mono Velikost pisave Prilagoditev velikosti besedila v aplikaciji Majhno @@ -1844,6 +2273,10 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Ponastavi na privzeto Prikaži lokacijo kot Doda Geohash vaše lokacije k objavi. Javnost bo vedela, da se nahajate v radiju 5km (3milj) od trenutne lokacije + Teleportiraj + ✈ Teleportiraj se sem + Izberi lokacijo na karti + Spremeni lokacijo na karti Lokacijsko-ekskluzivna objava To bodo videli samo sledilci lokacije. Vaši splošni sledilci tega ne bodo videli. Ekskluzivna objava ključnika @@ -1897,8 +2330,32 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Nova javna ali zasebna skupina + Začni pogovor + Najboljše za + Dobro + Kompromisi + Zasebno + Šifrirane skupine + Zavedanje o relejih + Zasebno sporočilo + Zasebna sporočila, šifrirana od konca do konca (E2EE) + 1:1–5 sob + Klepeti ena na ena in v manjših skupinah z ljudmi, ki jih poznate. + Novo sporočilo + 1 na 1 in manjše skupine + Šifrirano od konca do konca (E2EE) + Uporabnikov ni mogoče dodati pozneje + Marmot skupina + Večje zasebne skupine + Vezano na napravo + Večje zasebne skupine za tiste, ki prerasejo male zasebne klepete. + Ustvari skupino + Presega omejitve malih klepetov + Šifriran skupinski klepet + ezano na napravo — ne bo prikazano, če se prijavite drugje + Concord skupnost Skupine relejev - V vrstici + Vrstično Po releju Prikaži vsako skupino kot ločen pogovor, pomešan z vašimi klepeti. Strni skupine posameznega releja v eno vrstico, prikazano pri njegovem najnovejšem sporočilu. @@ -1906,6 +2363,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Skupine relejev Skupine Sporočila + Šifrirane skupnosti z več tematskimi kanali (Concord). Ustvari skupino Ta relej ne oglašuje podpore za skupine NIP-29. Skupina, ustvarjena tukaj, ne bo delovala – relej ne bo upravljal njenega imena, članov in sporočil. Izberite rele, ki podpira skupine na ravni releja. Ime supine @@ -2694,9 +3152,101 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov, Odstrani lokacijo Dodaj opozorilo o vsebini Odstrani opozorilo o vsebini + %1$s je ustvaril/-a kanal + %1$s je posodobil/-a profil kanala + (urejeno) + (razlika je odrezana) + Delovna površina + V tem delovnem prostoru še ni bila deljena nobena delovna površina. + Uredi delovno površino + Shrani delovno površino + DElovna površina (Markdown) + Uredi + Urejanje sporočila + Uredi + (urejeno) + Urejanje sporočila + %1$s tipka… + %1$s in %2$s tipkata… + Nekaj ljudi tipka… + Buzz delovni prostori + Kjer ljudje in agenti ustvarjajo skupaj + Zapusti delovni prostor + Vaši delovni prostori + Ni delovnih prostorov + Delovni prostori Buzz so releji, kjer ljudje in AI-agenti ustvarjajo skupaj. Pridružite se enemu, se prikaže tukaj. + Brskaj po skupinah + Konzola za agenta + Stroški · Liki · Opazovalec v živo + Nova tema na forumu + O čem želitš razpravljati? + Pošlji temo + Niz objav foruma + Še ni odgovorov. Bodi prvi, ki bo odgovoril. + Zasebna sporočila + ZS Buzz + Zasebni pogovori v vaših delovnih prostorih + Novo sporočilo + Ni še zasebnih sporočil + Začnite zasebni pogovor s komerkoli v delovnem prostoru Buzz. + Več + Skrij pogovore + Dodaj člana + Dodaj nekoga v to ZS + Neveljaven npub ali hex ključ + Samo vi + Delovni prostor + Za + Dodaj nekoga (npub ali hex) + Začni pogovor + Odpiram… + Odstrani + Povabilo v delovni prostor + Delovni prostor + Vloga + Delovni prostor se bo odprl v varnem brskalniku znotraj aplikacije, kjer si boste lahko ogledali pogoje uporabe in dokončali pridružitev. Pripet se boste z vašim ključem Amethyst — brez gesla. + Nadaljuj v brskaniku + To ne izgleda kot veljavna povezava za povabilo v Buzz. + To povabilo je poteklo. Zaprosite za novo. + Dokončajte sprejemanje pogojev v brskalniku in se nato vrnite — vaš delovni prostor vas bo čakal. + Odpri moje delovne prostore + Uvozi kanale delovnega prostora + Iskanje vaših kanalov… + Vaši kanali v tem delovnem prostoru + Dodaj + Dodaj vse + Dodano + Ni najdenih kanalov + Kanali + Forumi + Delam… + Dodaj člana + Dodaj ljudi v ta delovni prostor + Ustvari povezavo povabila + Povezava povabila + Deli + Kopiraj + Ne morem ustvarii povabila + OK + Pripni kanal + Odpni kanal + Še ni pogovorov + + Prikaži še %1$d pogovor + Prikaži še %1$d pogovora + Prikaži še %1$d pogovore + Prikaži vseh %1$d pogovorov + + Dostava sporočila + Zapri + Čakanje, da rele sprejme to sporočilo + Za to sporočilo ni podatkov o releju + Sprejeto s strani vsaj enega releja + Dostavljeno relejem vseh prejemnikov Dodaj datum poteka Odstrani datum poteka Datum poteka + Avtor je zahteval, da releji to opombo izbrišejo po %1$s. Ko poteče, bo izginila iz virov. Nostr odjemalci bodo po tem datumu skrili objavo (NIP-40) Izberite datum in čas poteka Se izteče čez %1$s @@ -2872,6 +3422,7 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov, Odpri Dokaz časovnega žiga Obstaja dokaz, da je bil ta zapisek podpisan pred %1$s. Dokaz je bil ožigosan v Bitcoin verigi blokov na ta datum in čas. + Za to objavo je bilo zahtevano dokazilo o časovnem žigu, ki čaka na zapis v verigo blokov Bitcoin. Postopek se običajno zaključi v nekaj urah. Uredi članek Uredi objavo Prošnja za izboljšavo objave @@ -2888,6 +3439,8 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov, Ni mogoče reagirati na osnutek zapiska Osnutke zapisa ni mogoče Zap-niti Osnutek zapiska + Osnutek + Nagrada Iz zapiska Iščem aplikacijo Zahteva poslana, čakam na odgovor @@ -2952,7 +3505,7 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov, Pripni svoje najljubše algoritme Tapni \"%1$s\" spodaj za brskanje med algoritmi. - Tapni %1$s ob viru vsebin, daga ohraniš tukaj. + Tapni %1$s ob viru vsebin, da ga ohraniš tukaj. Dodaj nekaj virov vsebin Dodaj več… Poizvedba pri %1$s za vir vsebin… @@ -3010,6 +3563,105 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov, Najdeno je poročilo o napaki Želite poslati poročilo o napaki Amethyst-u preko zasebnega sporočila? Vaši osebni podatki NE bojo posredovani. Pošlji + Uporaba virov aplikacije + Poraba baterije in podatkov, energija, omrežje, diagnostika ter glavna knjiga + Danes + Zadnjih 7 dni + Mobilni podatki danes + Wi-Fi danes + Uporaba releja danes + Danes v aplikaciji + Količina podatkov na dan + Wi-Fi (MB) + Aktivnost (7 dni) + Mobilni podatki (v ozadju) + Mobilni podatki (v aplikaciji) + Wi-Fi podatki + Čas povezave z releji + \u2026 v ozadju na mobilnih podatkih + Obdelava obvestil (CPU buden) + Izvedena opravila v ozadju + Neuspele ponovne povezave z relejem + Spletne zahteve + Obuditve radijskega modula za prenose (ocena) + Aktivni čas prenosa + Čas predvajanja + Preverjeni podpisi + Porabljen procesorski čas + Čas v aplikaciji + Zagoni procesov aplikacije + Dešifrirana sporočila + Oddaljeni podpisi + Rudarjenje PoW + Čas delovanja Tor + Čas trajanja klicev + Čas v zvočnih sobah + Spremljanje lokacije + Poraba baterije (v aplikaciji / v ozadju) + Storitev obvestil v ozadju + Ohranitve povezav z vašimireleji odprte tudi, ko je aplikacija zaprta, da obvestila prispijejo brez Googlovih potisnih strežnikov. Ko je možnost omogočena, predstavlja največji strošek baterije v ozadju. Če vam je baterija pomembnejša od trenutnih obvestil, lahko spremenite način dostave ali možnost izklopite. + Čas delovanja + + %1$d zagon + %1$d zagona + %1$d zagoni + %1$d zagonov + + Ohranjene povezave z releji med zaprtjem + Poraba baterije v tem času (celotna naprava) + Spremeni nastavitve obvestil + Možnosti zasebnosti + Baterija / ur v aplikaciji + Podatki / ur v aplikaciji + Povp. povezave z releji + Podatki po funkcijah (7 dni) + %1$s · %2$s skupno + Čas zaslona (7 dni) + Vgrajen Tor + Preusmerja promet aplikacije skozi omrežje Tor za večjo zasebnost. Med delovanjem porabi več baterije zaradi šifriranja in ohranjanja povezav, vsak zagon pa zahteva vzpostavitev povezave (bootstrap). Če vaš model groženj to dopušča, lahko uporabo Tora prilagodite ali izklopite. + Poraba podatkov po funkcijah (7 dni) + Sinhronizacija relejev + Slike + Video in zvok + Prenosi + Denarnica in zapi + Preverjanje naslova + Predogled povezav + Drugo + Še ni podatkov o uporabi + Trenutni pomnilnik + Pomnilnik aplikacije (heap) + Lokalni pomnilnik + Pomnilnik slik (RAM) + Pomnilnik slik (disk) + Zapiski v pomnilniku + Profili v pomnilniku + Dosegljivi dogodki v pomnilniku + Seznami sob v pomnilniku + Kategorija pomnilnika naprave + Pošlji razvijalcem + Če Amethyst porablja preveč baterije ali podatkov, lahko to poročilo pošljete razvijalcem v šifriranem neposrednem sporočilu (DM). Vsebuje le številke s tega zaslona in tehnične števce za njimi – brez objav, stikov ali podrobnosti o brskanju. Nič ne bo poslano, dokler na zaslonu s sporočilom ne tapnete Pošlji. + Pošlji poročilo prek ZS + Zaznana visoka poraba virov + Aplikacija Amethyst je nedavno porabila več virov kot pričakovano: %1$s. Želite razvijalcem poslati poročilo o uporabi v šifriranem zasebnem sporočilu (ZS)? Preden bo karkoli poslano, boste videli celotno poročilo. + %1$s mobilnih podatkov v ozadju v enem dnevu + %1$s povezav z releji v ozadju prek mobilnih podatkov v enem dnevu + Procesor je bil zaradi obdelave obvestil v enem dnevu buden %1$s + + Process aplikacije se je v enem dnevu znova zagnal %1$d-krat + Process aplikacije se je v enem dnevu znova zagnal %1$d-krat + Process aplikacije se je v enem dnevu znova zagnal %1$d-krat + Process aplikacije se je v enem dnevu znova zagnal %1$d-krat + + + Aplikacija se je z releji v enem dnevu znova povezala %1$d-krat + Aplikacija se je z releji v enem dnevu znova povezala %1$d-krat + Aplikacija se je z releji v enem dnevu znova povezala %1$d-krat + Aplikacija se je z releji v enem dnevu znova povezala %1$d-krat + + Preglej in pošlji + Ne zdaj + Ne sprašuj več To sporočilo bo izginilo čez %1$s Izberi podpisnika Že na seznamu @@ -3374,6 +4026,8 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov, Predvajaj Samodejno + Uredi vzdevek + Vidno le vam Prenesi v napravo Prenehaj s prenašanjem @@ -3569,6 +4223,7 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov, Doda se na konec sporočila pri ustvarjanju nove objave, odgovora, citata ali članka. Pustite prazno, da onemogočite. Vaš podpis Dokazilo o delu (PoW) + Avtor je porabil računsko moč za rudarjenje tega zapisa do zahtevnosti %1$s – dokaz o opravljenem delu (PoW), ki otežuje pošiljanje neželene pošte. Težavnost Pred objavo sporočila, rudari (NIP-13) dokaz o delu (PoW), da jih lahko releji in bralci ločijo od neželene vsebine (spam). Višje vrednosti zahtevajo eksponentno več časa za rudarjenje. Objave se po končanem rudarjenju samodejno objavijo v ozadju. Ugasni @@ -3632,6 +4287,11 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov, Rudarjenje z dokazom o delu (PoW) Prikazuje objave, ki še rudarijo svoj NIP-13 dokaz o delu, da lahko zaključijo postopek, potem ko zapustite aplikacijo. Prekliči + Pošlji zdaj + Prekinem rudarjenje? + Ta objava še vedno rudari dokaz o opravljenem delu. Lahko jo pošljete zdaj brez dokaza ali pa jo zavržete. + Pošlji brez PoW + Zavrzi objavo ≈ %1$s na objavo v tej napravi <1 s @@ -3763,6 +4423,10 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov, Vnesi ime skupine Vnesite opis skupine (neobvezno) Spremembe bodo potrjene prek MLS in poslane vsem članom skupine. + Ikona skupine + Dodaj fotografijo + Zamenjaj sliko + Odstrani fotografijo Podatki o skupini so posodobljeni Posodobitev ni uspela: %1$s Skupine ni bilo mogoče ustvariti: %1$s @@ -3818,4 +4482,14 @@ Ali želite za to uporabiti trenutne releje iz odhodnega predala? Napaka pri razčlenjevanju JSON med pridobivanjem fakture iz %1$s. Preverite uporabnikove nastavitve za \"Lightning\" Ni mogoče ustvariti lightning fakture. Sporočilo od %1$s: %2$s Ni mogoče ustvariti lightning fakture od %1$s: Element \'pr\' ni bil najden v JSON-u. + Odpri lokacijski klepet + Lokacijski klepet + Lokacijski kanal + Klepetajte z ljudmi v bližini lokacije (geohash) + Lokalno + Odlično za klepet z ljudmi v bližini – v soseski, mestu ali regiji. + Izberi lokacijo + Anonimno — enkratna identiteta za posamezno področje, nikoli vaš npub. + Združljivo z Bitchatom — doseže bližnje uporabnike na istih relejih. + Javno in minljivo — brez zgodovine sporočil, dostopno vsem v celici. diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 09f209ec53..e48dd0b27b 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -32,6 +32,7 @@ Dekrypterar… + Du: %1$s Grupp bild Explicit Innehåll Relay-meddelande @@ -344,6 +345,14 @@ %1$d kanal %1$d kanaler + + %1$d arbetsyta + %1$d arbetsytor + + + %1$d kanal + %1$d kanaler + %1$d medlem %1$d medlemmar @@ -400,6 +409,7 @@ Tråd + Skicka bild %1$d svar @@ -1250,6 +1260,7 @@ Starta utrymme Schemalägg för senare Välj starttid + Agentkonsol Nest-servrar Välj vilka MoQ-värdservrar Amethyst publicerar dina nests till. Den första posten används som standard när du startar ett nytt utrymme. Dina servrar @@ -1592,6 +1603,10 @@ Blossom-synk Visar förloppet medan dina filer kopieras mellan dina Blossom-servrar. Inga lagrade filer hittades på dina Blossom-servrar. + Lagrad på + Filinformation + På alla servrar + Inte på alla servrar Spegla till saknade Ta bort från… Ta bort från %1$s @@ -1609,6 +1624,33 @@ Rapportera blob Anledning (valfritt) Skicka + Fler åtgärder + Importera filer… + Importera filer + Sök på andra Blossom-servrar efter filer du laddat upp någon annanstans och kopiera dem till dina egna servrar. + Servrar att söka på + Aktivera alla + Inaktivera alla + Eller klistra in en serveradress + Sök på servrar + Söker… + Kunde inte nå den här servern + Inga nya filer hittades på de valda servrarna. + Lägg till dina egna Blossom-servrar först, så att de importerade filerna har någonstans att kopieras till. + Hantera mina servrar + En filsynkronisering pågår redan. Försök importera igen när den är klar. + + %1$d fil + %1$d filer + + + Hittade %1$d fil att importera till dina servrar. + Hittade %1$d filer att importera till dina servrar. + + + Importera %1$d fil + Importera %1$d filer + Rekommenderade Mediaservrar Amethysts standardlista. Du kan lägga till dem individuellt eller lägga till listan. Använd standardlistan @@ -1637,6 +1679,20 @@ Ingen app hittades för att hantera denna betalning. Installera en kompatibel plånbok. Ingen app installerad för att hantera %1$s-betalningar. Installera en kompatibel plånbok. Det gick inte att öppna betalningen + BOLT12-erbjudanden + Publicera återanvändbara BOLT12-erbjudanden så att andra kan zappa dig via Lightning utan en separat faktura varje gång. + Lägg till ett eller flera BOLT12-erbjudanden (lno1…). De delas offentligt så att vem som helst kan betala dig. + Inga BOLT12-erbjudanden angivna. Lägg till ett nedan ↓ + BOLT12-erbjudande (lno1…) + Inte ett giltigt BOLT12-erbjudande + Ta bort BOLT12-erbjudande + Betala med ansluten plånbok + Belopp (sats) + BOLT12-betalning skickad + BOLT12-betalningen misslyckades: %1$s + BOLT12-zapfel + Betalt via BOLT12, men plånboken returnerade inget bevis, så inget zapkvitto publicerades. + Betalt via BOLT12, men betalningsbeviset kunde inte valideras, så inget zapkvitto publicerades. Ej påbörjad Komprimerar Laddar upp @@ -1743,6 +1799,10 @@ %1$s sats Från %1$s till %1$s + Mottagna betalningar + Meddelar dig när din anslutna plånbok tar emot en betalning som inte är en Nostr-zap + Tog emot %1$s sats + Nya betalningar Svara Markera som läst Jag @@ -2367,6 +2427,12 @@ Reläer du är på Populära reläer Inga meddelanden ännu + Tillagd i %1$s + %1$s lade till dig i den här kanalen på %2$s. Visa den i Meddelanden? + Någon + Visa + Ignorera + Lämna Gå med i den här gruppen för att skicka meddelanden. Den här gruppen är endast för inbjudna — du behöver en inbjudan för att publicera. @@ -2385,6 +2451,10 @@ %1$d person du följer %1$d personer du följer + + %1$d grupp + %1$d grupper + Privat Till Ämne @@ -3069,9 +3139,86 @@ %1$s skapade kanalen %2$s %1$s skapade kanalen %1$s uppdaterade kanalprofilen + (redigerat) + (diff avkortad) + Ingen canvas har delats i den här arbetsytan än. + Redigera canvas + Spara canvas + Redigera + Redigerar meddelande + Redigera + (redigerat) + Redigerar meddelande + %1$s skriver… + %1$s och %2$s skriver… + Flera personer skriver… + Buzz-arbetsytor + Där människor och agenter bygger tillsammans + Lämna arbetsytan + Dina arbetsytor + Inga arbetsytor än + Buzz-arbetsytor är reläer där människor och AI-agenter bygger tillsammans. Gå med i en för att se den här. + Bläddra bland grupper + Agentkonsol + Kostnader · Personas · Liveobservatör + Nytt forumämne + Vad vill du diskutera? + Publicera ämne + Forumtråd + Inga svar än. Bli först med att svara. + Direktmeddelanden + Buzz-DM + Privata konversationer i dina arbetsytor + Nytt meddelande + Inga direktmeddelanden än + Starta en privat konversation med vem som helst i en Buzz-arbetsyta. + Mer + Dölj konversation + Lägg till medlem + Lägg till någon i det här DM:et + Inte en giltig npub- eller hexnyckel + Bara du + Arbetsyta + Till + Lägg till någon (npub eller hex) + Starta konversation + Öppnar… + Ta bort + Arbetsyteinbjudan + Gå med i den här arbetsytan + Arbetsyta + Roll + Du öppnar arbetsytan i en säker webbläsare i appen för att granska villkoren och slutföra anslutningen. Du loggas in med din Amethyst-nyckel — inget lösenord. + Fortsätt i webbläsaren + Det här ser inte ut som en giltig Buzz-inbjudningslänk. + Den här inbjudan har gått ut. Be om en ny. + Slutför godkännandet av villkoren i webbläsaren och kom sedan tillbaka — din arbetsyta väntar. + Öppna mina arbetsytor + Importera arbetsytans kanaler + Hittar dina kanaler… + Dina kanaler i den här arbetsytan + Lägg till + Lägg till alla + Tillagd + Inga kanaler hittades + Du verkar inte vara medlem i några kanaler på det här relät än. Acceptera arbetsyteinbjudan i webbläsaren först och försök sedan igen. Kanaler Forum + Arbetar… Lägg till medlem + Lägg till personer i den här arbetsytan + Skapa inbjudningslänk + Inbjudningslänk + Dela + Kopiera + Kunde inte skapa inbjudan + Fäst kanal + Lossa kanal + Inga konversationer än + + Visa %1$d konversation till + Visa alla %1$d konversationer + Meddelandeleverans Stäng Väntar på att ett relä ska acceptera detta meddelande diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 650860393c..167a715f3d 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2653,6 +2653,12 @@ Relays you\'re on Popular relays No messages yet + Added to %1$s + %1$s added you to this channel on %2$s. Show it in Messages? + Someone + Show + Ignore + Leave Join this group to send messages. This group is invite-only — you need an invite to post. @@ -3441,6 +3447,48 @@ %1$s updated the channel profile (edited) (diff truncated) + + + %1$s joined + %1$s was added by %2$s + %1$s left + %1$s was removed by %2$s + %1$s set the topic to \"%2$s\" + %1$s cleared the topic + %1$s set the purpose to \"%2$s\" + %1$s cleared the purpose + %1$s made this channel open — anyone can find and join it + %1$s made this channel private — invite only + %1$s changed the visibility to %2$s + %1$s set messages to disappear after %2$s + %1$s turned off disappearing messages + %1$s archived this channel + %1$s restored this channel + %1$s created this channel + %1$s deleted this channel + %1$s deleted a message + %1$s deleted a message: %2$s + %1$s started this conversation + + %1$s: %2$s + + + 🔊 %1$s started a huddle + 🔊 %1$s joined the huddle + 🔊 %1$s left the huddle + 🔊 Huddle ended + + + ⚙ %1$s requested a job + ⚙ %1$s accepted the job + ⚙ Job progress + ⚙ Job result + ⚙ Job cancelled + ⚠ Job error + + + ▲ %1$s upvoted a post + ▼ %1$s downvoted a post Canvas No canvas has been shared in this workspace yet. Edit canvas diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt index 2cd7d8c514..717c519036 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt @@ -62,7 +62,7 @@ import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent -import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.async diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/components/UnknownMediaAspectRatioTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/components/UnknownMediaAspectRatioTest.kt new file mode 100644 index 0000000000..26f7e230f8 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/components/UnknownMediaAspectRatioTest.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.components + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * The audio player sizes itself as a square (see AudioPlayerSquare.audioSquare), which is taller than + * 16:9. Handing audio the video default therefore cages the square in a shorter, centre-aligned box + * that nothing clips, and the player — visualizer included — paints over the note header above and + * the reactions row below. Audio must stay ratio-less so the box wraps the square instead. + */ +class UnknownMediaAspectRatioTest { + @Test + fun unknownVideoAssumes16by9() { + assertEquals(16f / 9f, unknownMediaAspectRatio(null, "https://example.com/a.mp4")) + } + + @Test + fun liveStreamPlaylistKeeps16by9() { + assertEquals(16f / 9f, unknownMediaAspectRatio(null, "https://example.com/stream.m3u8")) + } + + @Test + fun bareAudioUrlHasNoRatio() { + assertNull(unknownMediaAspectRatio(null, "https://haven.sdbitcoiners.com/f28a5a2e.mp3")) + } + + @Test + fun audioMimeHasNoRatioEvenWithoutAnExtension() { + assertNull(unknownMediaAspectRatio("audio/mpeg", "https://example.com/download?id=7")) + } + + @Test + fun videoMimeWinsOverNothing() { + assertEquals(16f / 9f, unknownMediaAspectRatio("video/mp4", "https://example.com/download?id=7")) + } + + @Test + fun unknownEverythingAssumes16by9() { + assertEquals(16f / 9f, unknownMediaAspectRatio(null, "https://example.com/download?id=7")) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/MinichatReplyTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/MinichatReplyTest.kt new file mode 100644 index 0000000000..acb9de2a98 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/MinichatReplyTest.kt @@ -0,0 +1,99 @@ +/* + * 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.chats + +import com.vitorpamplona.quartz.nipC7Chats.ChatEvent +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * A kind-9 chat message is a thread reply only when its `e` tag carries a NIP-10 marker. + * + * Three conventions share kind 9 and must not be confused: + * - **NIP-C7** spends `q` on the in-chat reply and never mentions `e` at all, which is what leaves a + * marked `e` free to mean "thread reply". + * - **WhiteNoise / Marmot** thread chat with a **plain, unmarked** `e` — an *in-chat* reply that has to + * keep rendering as a quote bubble in the timeline. + * - **Buzz** threads with `["e", id, "", "reply"]` (nested: `root` + `reply`), which belongs in the + * minichat and must be dropped from the channel timeline. + * + * Getting this wrong is what put a Buzz thread reply in the main channel as a quote instead of in the + * thread on its parent. + */ +class MinichatReplyTest { + private val parentId = "1a05130cc86929f267747b17761d5873a95dbab66d5298c38a352bdfd0edc730" + private val rootId = "bf2e60b69fdf6bf3aa11223344556677889900aabbccddeeff00112233445566" + private val channel = "6a39da2f-33c0-44f6-a050-c4da0138644a" + + private fun chat(vararg tags: Array) = + ChatEvent( + id = "id", + pubKey = "pk", + createdAt = 1L, + tags = arrayOf(arrayOf("h", channel), *tags), + content = "hi", + sig = "sig", + ) + + /** The exact shape observed on the wire from Buzz's client for a direct reply. */ + @Test + fun `buzz direct reply - reply-marked e tag - is a thread reply`() { + assertTrue(isMinichatReply(chat(arrayOf("e", parentId, "", "reply")))) + } + + /** Nested reply: `root` + `reply`, matching Buzz's `_buildReplyTags`. */ + @Test + fun `buzz nested reply - root plus reply markers - is a thread reply`() { + assertTrue( + isMinichatReply( + chat(arrayOf("e", rootId, "", "root"), arrayOf("e", parentId, "", "reply")), + ), + ) + } + + /** + * Regression: WhiteNoise/Marmot use a bare `e`, which is an *in-chat* reply. Matching on the tag + * rather than the marker would swallow those into the minichat and empty the timeline. + */ + @Test + fun `whitenoise unmarked e tag stays an in-chat reply`() { + assertFalse(isMinichatReply(chat(arrayOf("e", parentId)))) + assertFalse(isMinichatReply(chat(arrayOf("e", parentId, "")))) + } + + /** NIP-C7's own reply mechanism renders inline, not in a thread. */ + @Test + fun `nip-c7 q tag reply stays an in-chat reply`() { + assertFalse(isMinichatReply(chat(arrayOf("q", parentId, "", "pk")))) + } + + @Test + fun `a plain top-level chat message is not a thread reply`() { + assertFalse(isMinichatReply(chat())) + } + + /** A `root`-only marker (no `reply`) is a thread root reference, not a reply to that message. */ + @Test + fun `root marker alone is not a reply`() { + assertFalse(isMinichatReply(chat(arrayOf("e", rootId, "", "root")))) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/BuzzTimelineKindsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/BuzzTimelineKindsTest.kt index 560d35cfc4..f189e58d32 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/BuzzTimelineKindsTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/BuzzTimelineKindsTest.kt @@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relay import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip29RelayGroups.GroupId -import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.GroupTag import org.junit.Assert.assertTrue import org.junit.Test @@ -50,10 +49,9 @@ class BuzzTimelineKindsTest { assertTrue(openTail.filter.kinds!!.contains(StreamMessageV2Event.KIND)) val fleet = - buildRelayGroupJoinedChatTailFilters( - listOf(GroupTag("g1", buzzRelay.url), GroupTag("g2", vanillaRelay.url)), - sinceEpoch = 0L, - ) + listOf(GroupId("g1", buzzRelay), GroupId("g2", vanillaRelay)).map { + buildRelayGroupJoinedChatTailFilter(it, sinceEpoch = 0L) + } fleet.forEach { assertTrue(it.filter.kinds!!.contains(StreamMessageV2Event.KIND)) } } } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuildersTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuildersTest.kt index d349a5a301..cfeec285ec 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuildersTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuildersTest.kt @@ -24,13 +24,16 @@ import com.vitorpamplona.quartz.buzz.forum.ForumCommentEvent import com.vitorpamplona.quartz.buzz.forum.ForumPostEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.FiltersChanged import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip29RelayGroups.GroupId import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.SupportedRolesEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.DeleteEventEvent import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.GroupTag import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent @@ -110,21 +113,96 @@ class RelayGroupFilterBuildersTest { filters.filter { it.relay == relayB }.forEach { assertNull(it.filter.since) } } - // --- Joined chat tail (always-on): batched #h per relay, time floor, NO per-group limit --- + // --- Joined chat tail (always-on): ONE single-valued #h per group, time floor, NO limit --- @Test - fun `joined chat tail batches one h-filter per relay with no count limit`() { - val filters = buildRelayGroupJoinedChatTailFilters(joined, 999L) - assertEquals(2, filters.size) + fun `joined chat tail carries exactly one h value per group with no count limit`() { + val filter = buildRelayGroupJoinedChatTailFilter(GroupId("g1", relayA), 999L) - val a = filters.single { it.relay == relayA } - // The joined tail now always carries the Buzz timeline kinds too (see + assertEquals(relayA, filter.relay) + // The joined tail always carries the Buzz timeline kinds too (see // RELAY_GROUP_ALL_TIMELINE_KINDS / BuzzTimelineKindsTest). - assertEquals(timelineKinds + BUZZ_RELAY_GROUP_TIMELINE_EXTRA_KINDS, a.filter.kinds) - assertEquals(setOf("g1", "g2"), a.filter.tags!!["h"]!!.toSet()) - assertEquals(999L, a.filter.since) - assertNull("a time-floored tail must NOT cap by count (that is what lets it batch)", a.filter.limit) - assertNull(a.filter.until) + assertEquals(timelineKinds + BUZZ_RELAY_GROUP_TIMELINE_EXTRA_KINDS, filter.filter.kinds) + assertEquals(listOf("g1"), filter.filter.tags!!["h"]) + assertEquals(999L, filter.filter.since) + assertNull("a time-floored tail must NOT cap by count", filter.filter.limit) + assertNull(filter.filter.until) + } + + /** + * Regression guard for the Messages-list freeze: `block/buzz` indexes a live subscription under a + * single channel uuid resolved from `#h`, and downgrades any subscription whose filters mention two + * or more distinct ids to a *global* one — which by design receives no channel-scoped events. The + * stored query still answers correctly, so the symptom was "backfills on launch, then never updates". + * Each joined group must therefore get its own single-valued filter (and its own subscription). + */ + @Test + fun `joined chat tail never batches multiple groups into one h filter`() { + joined.forEach { tag -> + val relay = RelayUrlNormalizer.normalizeOrNull(tag.relayUrl)!! + val values = buildRelayGroupJoinedChatTailFilter(GroupId(tag.groupId, relay), 1L).filter.tags!!["h"]!! + assertEquals("a multi-value #h makes buzz register the sub as global and stop live delivery", 1, values.size) + assertEquals(tag.groupId, values.single()) + } + } + + /** + * The Messages-row preview is bounded by COUNT, never by time. The chat tail floors at `now - 7 days` + * to keep recent chat warm; on its own that leaves a channel quiet for longer stuck on its + * "No messages yet" placeholder forever, sorted to the bottom by `createdAt = 0`. Roster-driven + * protocols fill their rows this way — NIP-28 with `limit = 1`, Concord with `limit = 10` — because + * their row set comes from a list event and only needs one newest message per row. + */ + @Test + fun `preview filter asks for one newest message with no time floor on a cold start`() { + val filter = buildRelayGroupPreviewFilter(g1OnA, null) + + assertEquals(relayA, filter.relay) + assertEquals(listOf("g1"), filter.filter.tags!!["h"]) + assertEquals(1, filter.filter.limit) + assertNull("a cold start must reach back past any window to fill the row", filter.filter.since) + assertNull(filter.filter.until) + } + + @Test + fun `preview filter rides the per-relay EOSE since once caught up`() { + assertEquals(555L, buildRelayGroupPreviewFilter(g1OnA, 555L).filter.since) + } + + /** Single-valued `#h`, so it can share the channel-scoped subscription without downgrading it. */ + @Test + fun `preview filter names exactly one channel`() { + assertEquals(1, buildRelayGroupPreviewFilter(g1OnA, null).filter.tags!!["h"]!!.size) + } + + /** + * Reactions/deletions ride the channel's own `#h` subscription, the way the Buzz reference client + * does it (its `channelEventKinds` bundles deletion + reaction into the one `#h` channel REQ). + * Amethyst's other source of reactions is the shared `#e` EventFinder query, which carries no `#h` + * and is therefore registered as a *global* subscription — a class that never receives + * channel-scoped events live, and a reaction inherits its target message's channel. + */ + @Test + fun `aux filter carries reactions and deletions scoped to one group`() { + val filter = buildRelayGroupAuxFilter(GroupId("g1", relayA), 42L) + + assertEquals(relayA, filter.relay) + assertEquals(listOf(DeletionEvent.KIND, ReactionEvent.KIND, DeleteEventEvent.KIND), filter.filter.kinds) + assertEquals(listOf("g1"), filter.filter.tags!!["h"]) + assertEquals(42L, filter.filter.since) + assertEquals(RELAY_GROUP_AUX_LIMIT, filter.filter.limit) + } + + /** + * The aux kinds must stay OUT of the timeline set: that set feeds the backward history pager, whose + * `until` cursor walks `created_at`. Reactions eating a page's `limit` would advance the cursor past + * chat messages that were never delivered. + */ + @Test + fun `aux kinds are not in the timeline set the history pager pages over`() { + RELAY_GROUP_AUX_KINDS.forEach { + assertFalse("aux kind $it must not consume history-page limit", it in RELAY_GROUP_ALL_TIMELINE_KINDS) + } } // --- Open chat tail: a single group's recent #h on the host relay --- @@ -239,7 +317,6 @@ class RelayGroupFilterBuildersTest { @Test fun `empty joined set produces no filters`() { assertTrue(buildRelayGroupStateFilters(emptySet()) { null }.isEmpty()) - assertTrue(buildRelayGroupJoinedChatTailFilters(emptySet(), 1L).isEmpty()) } // --- Reconnect stability: a `since`-only bump must NOT trigger a fresh REQ (no full replay) --- @@ -265,8 +342,9 @@ class RelayGroupFilterBuildersTest { @Test fun `joined chat tail advancing its time floor is not a resend`() { - val earlier = buildRelayGroupJoinedChatTailFilters(joined, 1_000L).map { it.filter } - val later = buildRelayGroupJoinedChatTailFilters(joined, 2_000L).map { it.filter } // recentBoundary() crept forward + val groups = joined.map { GroupId(it.groupId, RelayUrlNormalizer.normalizeOrNull(it.relayUrl)!!) } + val earlier = groups.map { buildRelayGroupJoinedChatTailFilter(it, 1_000L).filter } + val later = groups.map { buildRelayGroupJoinedChatTailFilter(it, 2_000L).filter } // recentBoundary() crept forward earlier.zip(later).forEach { (old, new) -> assertFalse( "a forward recent-tail floor bump is since-only → reconnect re-REQs the tail, not a full page", diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/tor/ArtiGuardStateTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/tor/ArtiGuardStateTest.kt index e244501338..cdc5a9e75a 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/tor/ArtiGuardStateTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/tor/ArtiGuardStateTest.kt @@ -100,6 +100,75 @@ class ArtiGuardStateTest { assertTrue(ArtiGuardState.hasConfirmedGuard(root)) } + /** + * The field case this ratio rule exists for: a 60-guard sample with 59 permanently disabled and + * exactly ONE still nominally usable, while Arti rejected all sixty at runtime + * (`AllGuardsDown { n_accepted: 0, n_rejected: 60 }`) across repeated app restarts. Under the old + * `usable == 0` rule the lone survivor vetoed the wipe forever, so Tor stayed ~87% broken and + * Concord never folded. + */ + @Test + fun `one usable guard out of sixty is wedged, not healthy`() { + val guards = + buildString { + append("""{ "default": { "guards": [""") + repeat(59) { + append("""{ "confirmed_at": "2026-07-01T00:00:00Z", "disabled": { "type": "TooManyIndeterminateFailures" }, "unlisted_since": null },""") + } + append("""{ "confirmed_at": null, "disabled": null, "unlisted_since": null }""") + append("] } }") + } + val root = ArtiGuardState.parse(guards) + + assertTrue( + "1 usable of 60 is unrecoverable in practice → must wipe", + ArtiGuardState.hasNoUsableGuards(root), + ) + } + + /** + * The opposite guard-rail: a young sample must never be wiped just for being small, or a genuine + * first bootstrap would loop. Only the strict `usable == 0` rule applies below + * [ArtiGuardState.MIN_SAMPLE_TO_JUDGE_RATIO]. + */ + @Test + fun `small fresh sample is never wedged by the ratio rule`() { + val root = + ArtiGuardState.parse( + """ + { "default": { "guards": [ + { "confirmed_at": null, "disabled": null, "unlisted_since": null }, + { "confirmed_at": null, "disabled": { "type": "TooManyIndeterminateFailures" } }, + { "confirmed_at": null, "disabled": { "type": "TooManyIndeterminateFailures" } } + ] } } + """.trimIndent(), + ) + + assertFalse( + "1 usable of 3 is a young sample Arti is still filling → leave it alone", + ArtiGuardState.hasNoUsableGuards(root), + ) + } + + /** A healthy majority must not trip the ratio rule. */ + @Test + fun `mostly usable large sample is not wedged`() { + val guards = + buildString { + append("""{ "default": { "guards": [""") + repeat(20) { + append("""{ "confirmed_at": null, "disabled": null, "unlisted_since": null },""") + } + append("""{ "confirmed_at": null, "disabled": { "type": "TooManyIndeterminateFailures" } }""") + append("] } }") + } + + assertFalse( + "20 usable of 21 is healthy", + ArtiGuardState.hasNoUsableGuards(ArtiGuardState.parse(guards)), + ) + } + @Test fun `empty selection is neither wedged nor confirmed`() { val root = ArtiGuardState.parse("""{ "default": { "guards": [] }, "restricted": { "guards": [] } }""") diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/tor/TorManagerTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/tor/TorManagerTest.kt index 94f148500d..f699969167 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/tor/TorManagerTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/tor/TorManagerTest.kt @@ -22,6 +22,8 @@ package com.vitorpamplona.amethyst.ui.tor import com.vitorpamplona.amethyst.commons.tor.TorType import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -506,6 +508,11 @@ private class FakeTorBackend : TorBackend { /** Simulates a persisted confirmed guard on disk (prior successful bootstrap). */ var bootstrappedBefore = false + /** Lets a test fire Arti's "every guard was rejected" signal (see [TorBackend.guardsDownSignal]). */ + val guardsDown = MutableSharedFlow(extraBufferCapacity = 1) + + override val guardsDownSignal: Flow = guardsDown + override suspend fun hasBootstrappedBefore(): Boolean = bootstrappedBefore override suspend fun start() { diff --git a/cli/README.md b/cli/README.md index 59a969c37c..d3f0591f78 100644 --- a/cli/README.md +++ b/cli/README.md @@ -715,7 +715,7 @@ output then also reports `paid` + the preimage. | `amy zap user USER SATS [--comment X] [--anon\|--private] [--with NDEBIT] [--timeout SECS]` | Profile zap: build the zap request and fetch a BOLT11 from USER's LN service. | | `amy zap event EVENT-ID SATS [--comment X] [--anon\|--private] [--with NDEBIT] [--timeout SECS]` | Same, attributed to a specific event (must be in the local store). Zap splits are honored — one invoice per recipient. | -### BOLT12 zaps (NIP-XX) +### BOLT12 zaps (NIP-B1) BOLT12 zaps (kinds 9736/9737, offers in kind:10058). amy has no NWC payment rail, so sending is a **two-step, out-of-band** flow: `bolt12 intent` signs the diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index b8dffda594..add1d87d06 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -63,7 +63,7 @@ Status legend: ✅ shipped · 📦 logic lives in `commons/`, needs a command · | Buzz agent jobs (`amy buzz job` / `agent serve`) | ✅ | `BuzzJobCommands` (request/list/show/cancel, kinds 43001-43006) + `BuzzAgentCommands` (`agent serve` responder loop: gate on `--accept-from`, run `--exec`, report accept/progress/result/error). Correlation + state via the shared `BuzzJobAggregator` in `commons`. Schema provisional (43001-43006 reserved upstream). See `cli/plans/2026-07-25-buzz-agent-support-channel.md`. | | NIP-51 lists (bookmarks, mute, follow sets) | 🆕 | `amethyst/model/nip51Lists/` | | NIP-57 zaps (send) | ✅ partial | `ZapCommand` — `zap user`/`zap event` build the kind:9734 request and fetch the BOLT11 (zap splits honored, one invoice per recipient); `--with NDEBIT` auto-pays through a CLINK debit pointer. Receipt (kind:9735) verification still 🆕. | -| BOLT12 zaps (NIP-XX, kinds 9736/9737/10058) | ✅ partial | `Bolt12Commands` + `Bolt12SendCommands` over shared `commons` `Bolt12ZapActions` — `bolt12 decode` (offer/proof), `verify` (validate a kind:9736), `offer get/set` (kind:10058), and the two-step send `intent`→`zap` (out-of-band proof, since amy has no NWC rail). Interop harness + NWC-fetched proofs still 🆕. | +| BOLT12 zaps (NIP-B1, kinds 9736/9737/10058) | ✅ partial | `Bolt12Commands` + `Bolt12SendCommands` over shared `commons` `Bolt12ZapActions` — `bolt12 decode` (offer/proof), `verify` (validate a kind:9736), `offer get/set` (kind:10058), and the two-step send `intent`→`zap` (out-of-band proof, since amy has no NWC rail). Interop harness + NWC-fetched proofs still 🆕. | | NIP-65 outbox model queries | ✅ | `OutboxCommand` — `amy outbox USER [--refresh]`, cache-first. | | CLINK offers + debits (`amy offer` / `amy debit`) | ✅ | `OfferCommands` + `DebitCommands` — pointer decode, NIP-05 discover, kind:21001/21002 round-trips, `offer pay --with NDEBIT` end-to-end settlement. `--timeout` is SECONDS. | | Geochat (Bitchat geohash, ephemeral kind:20000) | ✅ | `GeochatCommands` — listen/send/keys with per-geohash throwaway identity + geo-nearest relay routing; doubles as the Bitchat interop harness. | diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index 1a07da75cf..04ae351ea4 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -750,7 +750,7 @@ private fun printUsage() { | [--comment X] [--anon|--private] event (must be in local store) | [--timeout SECS] | - |BOLT12 zaps (NIP-XX): + |BOLT12 zaps (NIP-B1): | bolt12 decode LNO1|LNP1 decode a BOLT12 offer or payer proof | bolt12 verify EVENT-ID validate a kind:9736 zap in the local store | bolt12 offer get USER fetch + show a user's kind:10058 offers diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Bolt12Commands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Bolt12Commands.kt index 13f0217f41..a2b545916a 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Bolt12Commands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Bolt12Commands.kt @@ -28,13 +28,13 @@ import com.vitorpamplona.amethyst.commons.actions.Bolt12ZapActions import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Bech32 -import com.vitorpamplona.quartz.nipXXBolt12Zaps.offer.Bolt12OfferListEvent -import com.vitorpamplona.quartz.nipXXBolt12Zaps.verify.Bolt12ZapValidation -import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.bolt12.Bolt12Bech32 +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.verify.Bolt12ZapValidation +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent /** - * `amy bolt12 …` — NIP-XX BOLT12 zaps from the command line, for headless interop + * `amy bolt12 …` — NIP-B1 BOLT12 zaps from the command line, for headless interop * testing of the quartz BOLT12 stack. * * Sub-verbs: @@ -54,7 +54,7 @@ import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent object Bolt12Commands { val USAGE: String = """ - |BOLT12 zaps (NIP-XX): + |BOLT12 zaps (NIP-B1): | bolt12 decode LNO1|LNP1 decode an offer or payer proof to fields | bolt12 verify EVENT-ID validate a kind:9736 zap in the local store | bolt12 offer get USER fetch + show a user's kind:10058 offers diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Bolt12SendCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Bolt12SendCommands.kt index fa8acb2f58..5d63ee855f 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Bolt12SendCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Bolt12SendCommands.kt @@ -28,9 +28,9 @@ import com.vitorpamplona.amethyst.commons.actions.Bolt12ZapActions import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Bech32 -import com.vitorpamplona.quartz.nipXXBolt12Zaps.intent.Bolt12ZapIntentEvent -import com.vitorpamplona.quartz.nipXXBolt12Zaps.verify.Bolt12ZapValidation +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.bolt12.Bolt12Bech32 +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.intent.Bolt12ZapIntentEvent +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.verify.Bolt12ZapValidation /** * The BOLT12 send-side sub-verbs of `amy bolt12` (split from [Bolt12Commands] to keep diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/Bolt12ZapActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/Bolt12ZapActions.kt index 3875931c94..3a0cc51266 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/Bolt12ZapActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/Bolt12ZapActions.kt @@ -24,19 +24,19 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Bech32 -import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Offer -import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12PayerProof -import com.vitorpamplona.quartz.nipXXBolt12Zaps.builder.Bolt12ZapBuilder -import com.vitorpamplona.quartz.nipXXBolt12Zaps.intent.Bolt12ZapIntentEvent -import com.vitorpamplona.quartz.nipXXBolt12Zaps.offer.Bolt12OfferListEvent -import com.vitorpamplona.quartz.nipXXBolt12Zaps.verify.Bolt12ZapValidation -import com.vitorpamplona.quartz.nipXXBolt12Zaps.verify.Bolt12ZapValidator -import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.bolt12.Bolt12Bech32 +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.bolt12.Bolt12Offer +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.bolt12.Bolt12PayerProof +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.builder.Bolt12ZapBuilder +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.intent.Bolt12ZapIntentEvent +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.verify.Bolt12ZapValidation +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.verify.Bolt12ZapValidator +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.utils.Hex /** - * NIP-XX BOLT12 zap building, decoding, and validation — the shared, UI-free + * NIP-B1 BOLT12 zap building, decoding, and validation — the shared, UI-free * surface a non-Android caller (amy CLI, interop harnesses) drives. * * Assembly-only: it re-exposes [Bolt12ZapBuilder] / [Bolt12ZapValidator] and the diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Bolt12ZapEntry.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Bolt12ZapEntry.kt index 5a5cf7c015..151b5fde8f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Bolt12ZapEntry.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Bolt12ZapEntry.kt @@ -23,12 +23,12 @@ package com.vitorpamplona.amethyst.commons.model import androidx.compose.runtime.Stable /** - * Per-payment NIP-XX BOLT12 zap entry attached to a target Note. + * Per-payment NIP-B1 BOLT12 zap entry attached to a target Note. * * Unlike NIP-BC onchain zaps (which carry an async chain-verification state * machine), a BOLT12 zap is validated **synchronously** at consumption time — * the `lnp` payer proof is a self-contained cryptographic settlement proof — so - * every entry stored here has already passed [com.vitorpamplona.quartz.nipXXBolt12Zaps.verify.Bolt12ZapValidator] + * every entry stored here has already passed [com.vitorpamplona.quartz.nipB1Bolt12Zaps.verify.Bolt12ZapValidator] * and its amount is counted directly, the same way a NIP-57 lightning zap * receipt's amount is. * diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt index 146811e496..1a6f7c803f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt @@ -287,7 +287,7 @@ open class Note( private set /** - * NIP-XX BOLT12 zaps (kind 9736) targeting this note. + * NIP-B1 BOLT12 zaps (kind 9736) targeting this note. * Key: the payer proof's `invoice_payment_hash` (hex) — the spec's dedup key, * so two zap events proving the same settled payment collapse to one entry. * Value: entry with the source Bolt12ZapEvent note (so `source.author` is the @@ -829,7 +829,7 @@ open class Note( } else { // Same verification status: dedup by the settled payment and, if amounts // differ, keep the LOWER — so a re-publish with a bigger amount tag can't - // inflate the total (NIP-XX). Both share the same cryptoVerified flag. + // inflate the total (NIP-B1). Both share the same cryptoVerified flag. if (entry.amountMillisats < existing.amountMillisats) entry else existing } if (merged == existing) return@withLock false @@ -846,10 +846,10 @@ open class Note( } /** - * Register a NIP-XX BOLT12 zap targeting this note. [source] is the kind:9736 + * Register a NIP-B1 BOLT12 zap targeting this note. [source] is the kind:9736 * event's own note — `source.author` is the payer shown in the reactions * gallery and notifications. [amountMillisats] and [cryptoVerified] come from - * the synchronous [com.vitorpamplona.quartz.nipXXBolt12Zaps.verify.Bolt12ZapValidator] + * the synchronous [com.vitorpamplona.quartz.nipB1Bolt12Zaps.verify.Bolt12ZapValidator] * verdict; the caller MUST only call this for a `Valid` result. Deduplicated by * [paymentHashHex] (the proof's `invoice_payment_hash`). */ @@ -1189,7 +1189,7 @@ open class Note( sumOfAmounts += BigDecimal(entry.claimedSats) } - // NIP-XX BOLT12 zaps — count only the crypto-verified ones. An unverified + // NIP-B1 BOLT12 zaps — count only the crypto-verified ones. An unverified // (compressed, or offer-unbindable) proof carries a self-chosen preimage, // amount, and payment hash with no settled-payment guarantee, so counting it // would let anyone inflate a note's total for free. Unverified entries stay diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadAssembler.kt index ddffadc87c..51bff82663 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadAssembler.kt @@ -30,8 +30,8 @@ import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent -import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.toImmutableSet diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt index 9b48c1555c..e148a7b529 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt @@ -41,7 +41,7 @@ import com.vitorpamplona.quartz.nip19Bech32.toNpub import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent import com.vitorpamplona.quartz.nip61Nutzaps.info.tags.NutzapMintTag import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent -import com.vitorpamplona.quartz.nipXXBolt12Zaps.offer.Bolt12OfferListEvent +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent import com.vitorpamplona.quartz.utils.Hex import kotlin.concurrent.Volatile @@ -116,7 +116,7 @@ class User( fun nutzapInfo() = nutzapInfoNote.event as? NutzapInfoEvent - /** This user's published BOLT12 offer list (NIP-XX kind 10058), or null if none seen. */ + /** This user's published BOLT12 offer list (NIP-B1 kind 10058), or null if none seen. */ fun bolt12OfferList() = bolt12OfferListNote.event as? Bolt12OfferListEvent /** The canonical raw BOLT12 offers (`lno1...`) this user accepts, empty when none published. */ diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzChannelInvites.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzChannelInvites.kt new file mode 100644 index 0000000000..46f704c4ee --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzChannelInvites.kt @@ -0,0 +1,111 @@ +/* + * 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.commons.model.buzz + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.withLock +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** Somebody added [viewer] to a channel: who did it, where, and when. */ +@Immutable +class BuzzChannelInvite( + val channelId: String, + val relay: NormalizedRelayUrl, + val actor: HexKey?, + val createdAt: Long, +) + +/** + * App-wide, per-viewer set of channels somebody **else** added the viewer to, awaiting the viewer's + * decision about whether they appear on Messages. + * + * On a Buzz relay, membership is server-side: another member issues the add, the relay writes you into + * the channel's kind-39002 roster, and you can immediately read and post. The relay then addresses you a + * kind-44100 with `{"actor": …}` naming who did it — and it emits the *same* kind for a self-join, with + * `actor == you`, which is the only thing separating the two cases. + * + * Amethyst used to funnel every 44100 into [BuzzDmChannels], which silently subscribed the viewer to the + * channel's messages while the Messages list — which reads the self-published kind-10009 — showed no row + * for it. So a channel could be simultaneously joined (relay roster, no Join button, composer enabled), + * streaming messages, and invisible. Only `t = dm` channels belong in [BuzzDmChannels]; everything else + * lands here until the viewer accepts. + * + * Accepting adds the group to kind-10009 (`Account.follow`), after which the normal joined-group path + * owns it and the entry is dropped. Dismissing is a *display* choice recorded in + * `AccountSettings.dismissedChannelInvites`; genuinely leaving is a kind-9022 `LeaveRequestEvent`, which + * is a different action because the viewer really is a member until the relay says otherwise. + */ +object BuzzChannelInvites { + private val lock = KmpLock() + private val byViewer = HashMap>() + private val mutableFlow = MutableStateFlow>>(emptyMap()) + + /** Per-viewer pending invites (`channelId` -> who/where/when). */ + val flow: StateFlow>> = mutableFlow + + /** + * Records that somebody added [viewer] to [channelId]. Returns true when this is newly seen, so + * callers can invalidate a feed; a repeat of the same (viewer, channel) returns false rather than + * churning the flow — the relay re-sends the notification on every reconnect. + */ + fun record( + viewer: HexKey, + invite: BuzzChannelInvite, + ): Boolean = + lock.withLock { + val invites = byViewer.getOrPut(viewer) { mutableMapOf() } + if (invites.containsKey(invite.channelId)) return@withLock false + invites[invite.channelId] = invite + mutableFlow.value = snapshot() + true + } + + /** + * Drops an invite once it is no longer pending — the viewer accepted it (now in kind-10009), left the + * channel, or the relay reported a kind-44101 removal. + */ + fun remove( + viewer: HexKey, + channelId: String, + ): Boolean = + lock.withLock { + val invites = byViewer[viewer] ?: return@withLock false + if (invites.remove(channelId) == null) return@withLock false + mutableFlow.value = snapshot() + true + } + + /** Invites pending for [viewer], possibly empty. */ + fun invitesFor(viewer: HexKey): Map = mutableFlow.value[viewer] ?: emptyMap() + + private fun snapshot(): Map> = byViewer.mapValues { it.value.toMap() } + + /** Test-only: clears all state so unit tests don't leak into each other. */ + fun clearForTesting() = + lock.withLock { + byViewer.clear() + mutableFlow.value = emptyMap() + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzDmChannels.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzDmChannels.kt index 0c66f460e2..0f4274e989 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzDmChannels.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzDmChannels.kt @@ -68,6 +68,23 @@ object BuzzDmChannels { true } + /** + * Drops a channel that turned out not to be a DM. Discovery provisionally records every kind-44100 + * here so the channel's kind-39000 can be fetched by id; once that reveals a `t` other than `dm` the + * entry is withdrawn, because the always-on DM tail must not silently subscribe the viewer to a named + * channel somebody added them to (see [BuzzChannelInvites]). + */ + fun remove( + viewer: HexKey, + channelId: String, + ): Boolean = + lock.withLock { + val channels = byViewer[viewer] ?: return@withLock false + if (channels.remove(channelId) == null) return@withLock false + mutableFlow.value = snapshot() + true + } + /** The DM channels [viewer] is in (`channelId` -> relay), possibly empty. */ fun channelsFor(viewer: HexKey): Map = mutableFlow.value[viewer] ?: emptyMap() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/NotificationKinds.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/NotificationKinds.kt index 9fc760dd29..1d911cd5bc 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/NotificationKinds.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/NotificationKinds.kt @@ -35,8 +35,8 @@ import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent -import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent /** * Nostr event kinds that can generate a notification when they tag the @@ -71,7 +71,7 @@ object NotificationKinds { NutzapEvent.KIND, // 9321 — NIP-61 Cashu nutzap LnZapEvent.KIND, // 9735 — NIP-57 zap receipt OnchainZapEvent.KIND, // 8333 — onchain zap - Bolt12ZapEvent.KIND, // 9736 — NIP-XX BOLT12 zap + Bolt12ZapEvent.KIND, // 9736 — NIP-B1 BOLT12 zap // NIP-17 file-header messages (encrypted file DMs) ChatMessageEncryptedFileHeaderEvent.KIND, ) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/MetaTagsParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/MetaTagsParser.kt index 1015b1a6aa..77092cadb2 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/MetaTagsParser.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/MetaTagsParser.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.commons.preview +import com.vitorpamplona.amethyst.commons.util.codePointToChars import kotlinx.collections.immutable.toImmutableMap data class MetaTag( @@ -179,13 +180,32 @@ object MetaTagsParser { ) fun replaceCharRefs(match: MatchResult): String { - val bcr = BASE_CHAR_REFS[match.groupValues[2]] + val isNumeric = match.groupValues[1].isNotEmpty() + val ref = match.groupValues[2] + val terminated = match.groupValues[3].isNotEmpty() + + // Numeric character references (" / ") must be terminated by ';' + if (isNumeric) { + if (!terminated) return match.value + val codePoint = + if (ref.startsWith("x", ignoreCase = true)) { + ref.drop(1).toIntOrNull(16) + } else { + ref.toIntOrNull(10) + } ?: return match.value + if (codePoint !in 0..0x10FFFF || codePoint in 0xD800..0xDFFF) { + return match.value + } + return codePointToChars(codePoint).concatToString() + } + + val bcr = BASE_CHAR_REFS[ref] if (bcr != null) { return bcr } // non-base char refs must be terminated by ';' - if (match.groupValues[3].isNotEmpty()) { - val cr = CHAR_REFS[match.groupValues[2]] + if (terminated) { + val cr = CHAR_REFS[ref] if (cr != null) { return cr } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt index 1f61884261..60f04f7578 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt @@ -495,12 +495,25 @@ class RichTextParser { ) val imageExt = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg", "avif") - val videoExt = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3", "m3u8", "ogg", "wav", "flac", "aac", "opus", "m4a", "f4a") + + // Audio is folded into [videoExt] because both play through the same video pipeline — but + // audio has no picture, so anything that reasons about the shape of the media (aspect + // ratios, player sizing) has to tell them apart. `m3u8` stays video-only: a playlist + // carries either. + // + // Composing [videoExt] out of [audioExt] is what keeps "every audio extension is also a + // video extension" true by construction rather than by convention — the two lists cannot + // drift apart. It is also why [audioExt] is declared first; the compiler rejects the + // reverse order outright ("Variable 'audioExt' must be initialized"), so this note is + // intent, not a guard rail. + val audioExt = listOf("mp3", "ogg", "wav", "flac", "aac", "opus", "m4a", "f4a") + val videoExt = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "m3u8") + audioExt val pdfExt = listOf("pdf") val imageExtensions = imageExt + imageExt.map { it.uppercase() } val videoExtensions = videoExt + videoExt.map { it.uppercase() } val pdfExtensions = pdfExt + pdfExt.map { it.uppercase() } + val audioExtensions = audioExt + audioExt.map { it.uppercase() } val tagIndex = Regex("\\#\\[([0-9]+)\\](.*)") val hashTagsPattern: Regex = @@ -512,14 +525,13 @@ class RichTextParser { it.uppercase() } - private fun removeQueryParamsForExtensionComparison(fullUrl: String): String = - if (fullUrl.contains("?")) { - fullUrl.split("?")[0] - } else if (fullUrl.contains("#")) { - fullUrl.split("#")[0] - } else { - fullUrl - } + private fun removeQueryParamsForExtensionComparison(fullUrl: String): String { + // Called per URL during feed render — substringBefore allocates nothing when the + // separator is absent, unlike split(). + val queryStart = fullUrl.indexOf('?') + if (queryStart >= 0) return fullUrl.substring(0, queryStart) + return fullUrl.substringBefore('#') + } fun isImageExtension(ext: String) = imageExtensions.any { it == ext } @@ -542,6 +554,18 @@ class RichTextParser { return videoExtensions.any { removedParamsFromUrl.endsWith(it) } } + fun isAudioUrl(url: String): Boolean { + val removedParamsFromUrl = removeQueryParamsForExtensionComparison(url) + return audioExtensions.any { removedParamsFromUrl.endsWith(it) } + } + + // A declared MIME type is authoritative when present; the URL extension is only a fallback + // for the common bare-URL case. + fun isAudioContent( + mimeType: String?, + url: String, + ): Boolean = mimeType?.startsWith("audio/") ?: isAudioUrl(url) + // Mirrors the canonical HLS-playlist MIME list also kept in MediaItemCache.toExoPlayerMimeType. // Called per URL during feed render — uses `equals(ignoreCase)` instead of `lowercase()` to // avoid a per-call String allocation on the common non-HLS path. diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/LiveStreamTopZappersViewModel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/LiveStreamTopZappersViewModel.kt index 51e0ccfb18..5fe963a9cd 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/LiveStreamTopZappersViewModel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/LiveStreamTopZappersViewModel.kt @@ -32,7 +32,7 @@ import com.vitorpamplona.amethyst.commons.nip53LiveActivities.ZapContribution import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent -import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.Job diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomZapsState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomZapsState.kt index 052d1d09ac..8568f793f4 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomZapsState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomZapsState.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.commons.viewmodels import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent -import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent /** * One in-flight kind-9735 zap to render as a floating overlay on the diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/Bolt12ZapActionsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/Bolt12ZapActionsTest.kt index fabbf01868..92c68bfc4b 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/Bolt12ZapActionsTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/Bolt12ZapActionsTest.kt @@ -22,11 +22,11 @@ package com.vitorpamplona.amethyst.commons.actions import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal -import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Bech32 -import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Offer -import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Values -import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.TlvRecord -import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.TlvStream +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.bolt12.Bolt12Bech32 +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.bolt12.Bolt12Offer +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.bolt12.Bolt12Values +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.bolt12.TlvRecord +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.bolt12.TlvStream import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteBolt12ZapTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteBolt12ZapTest.kt index 72f17ca609..0213f107e4 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteBolt12ZapTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteBolt12ZapTest.kt @@ -26,7 +26,7 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue /** - * Verifies how validated NIP-XX BOLT12 zaps fold into a Note's aggregate zap + * Verifies how validated NIP-B1 BOLT12 zaps fold into a Note's aggregate zap * accounting — the model contract the LocalCache ingest path and the reaction-row * counter depend on. */ @@ -57,7 +57,7 @@ class NoteBolt12ZapTest { @Test fun keepsTheLowerAmountWhenTwoProofsShareAPaymentHash() { - // NIP-XX: same proof identifier, differing amounts → count the LOWER, in either order. + // NIP-B1: same proof identifier, differing amounts → count the LOWER, in either order. val a = note("a".repeat(64)) a.addBolt12Zap(note("b".repeat(64)), "h", amountMillisats = 21_000_000L, cryptoVerified = true) a.addBolt12Zap(note("c".repeat(64)), "h", amountMillisats = 5_000_000L, cryptoVerified = true) diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/preview/MetaTagsParserCharRefTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/preview/MetaTagsParserCharRefTest.kt new file mode 100644 index 0000000000..09056a3f2f --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/preview/MetaTagsParserCharRefTest.kt @@ -0,0 +1,45 @@ +/* + * 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.commons.preview + +import kotlin.test.Test +import kotlin.test.assertEquals + +class MetaTagsParserCharRefTest { + @Test + fun decodesNumericCharacterReferencesInContent() { + val input = + """ + | + | + | + | + """.trimMargin() + + val metaTags = MetaTagsParser.parse(input).toList() + assertEquals(2, metaTags.size) + assertEquals( + "\"You can always buy more tokens, not more time.\"", + metaTags[0].attr("content"), + ) + assertEquals("\"hex quotes\"", metaTags[1].attr("content")) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserAudioUrlTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserAudioUrlTest.kt new file mode 100644 index 0000000000..f8537790a1 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserAudioUrlTest.kt @@ -0,0 +1,81 @@ +/* + * 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.commons.richtext + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class RichTextParserAudioUrlTest { + @Test + fun mp3IsAudio() { + assertTrue(RichTextParser.isAudioUrl("https://haven.sdbitcoiners.com/f28a5a2e.mp3")) + } + + @Test + fun everyAudioContainerIsAudio() { + RichTextParser.audioExt.forEach { + assertTrue(RichTextParser.isAudioUrl("https://example.com/a.$it"), it) + } + } + + @Test + fun uppercaseIsAudio() { + assertTrue(RichTextParser.isAudioUrl("https://example.com/A.MP3")) + } + + @Test + fun queryParamsAndFragmentsAreIgnored() { + assertTrue(RichTextParser.isAudioUrl("https://example.com/a.mp3?x=1")) + assertTrue(RichTextParser.isAudioUrl("https://example.com/a.mp3#t=10")) + } + + @Test + fun videoIsNotAudio() { + assertFalse(RichTextParser.isAudioUrl("https://example.com/a.mp4")) + assertFalse(RichTextParser.isAudioUrl("https://example.com/a.webm")) + assertFalse(RichTextParser.isAudioUrl("https://example.com/a.mov")) + } + + @Test + fun hlsPlaylistIsNotAudio() { + // A .m3u8 carries either, and a live stream is the reason the 16:9 default exists. + assertFalse(RichTextParser.isAudioUrl("https://example.com/stream.m3u8")) + } + + @Test + fun imagesAndUnknownAreNotAudio() { + assertFalse(RichTextParser.isAudioUrl("https://example.com/a.jpg")) + assertFalse(RichTextParser.isAudioUrl("https://example.com/nothing")) + } + + @Test + fun mimeTypeIsAuthoritativeOverTheUrl() { + assertTrue(RichTextParser.isAudioContent("audio/mpeg", "https://example.com/download?id=7")) + assertFalse(RichTextParser.isAudioContent("video/mp4", "https://example.com/a.mp3")) + } + + @Test + fun urlExtensionIsTheFallbackWithoutAMimeType() { + assertTrue(RichTextParser.isAudioContent(null, "https://example.com/a.mp3")) + assertFalse(RichTextParser.isAudioContent(null, "https://example.com/a.mp4")) + } +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt index 39e7d8743f..6fb1a8088a 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt @@ -45,7 +45,7 @@ import com.vitorpamplona.nestsclient.connectReconnectingNestsSpeaker import com.vitorpamplona.nestsclient.transport.WebTransportFactory import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent -import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentSetOf diff --git a/docs/changelog/translators.json b/docs/changelog/translators.json index 7cb6ac1e52..b4313ad10e 100644 --- a/docs/changelog/translators.json +++ b/docs/changelog/translators.json @@ -135,6 +135,12 @@ "Hindi" ] }, + { + "user": "StellarStoic", + "languages": [ + "Slovenian" + ] + }, { "user": "hypnotichemionus4", "languages": [ @@ -147,12 +153,6 @@ "Hungarian" ] }, - { - "user": "StellarStoic", - "languages": [ - "Slovenian" - ] - }, { "user": "anthony-robin", "languages": [ diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/notifications/MemberAddedNotificationEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/notifications/MemberAddedNotificationEvent.kt index e16a251e84..e4dfbbf685 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/notifications/MemberAddedNotificationEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/notifications/MemberAddedNotificationEvent.kt @@ -49,6 +49,16 @@ class MemberAddedNotificationEvent( /** The channel UUID the member was added to - the `h` tag. */ fun channel() = tags.notificationChannel() + /** + * The relay-reported body: who performed the change ([MembershipNotificationContent.actor]) and the + * channel it applies to. The relay emits this kind for a self-join too, so the actor is what + * separates "I joined" from "somebody added me" — see [MembershipNotificationContent]. + */ + fun notification() = MembershipNotificationContent.parse(content) + + /** The pubkey that performed the add/remove, or null when the body is missing or malformed. */ + fun actor() = notification()?.actor + companion object { const val KIND = 44100 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/notifications/MemberRemovedNotificationEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/notifications/MemberRemovedNotificationEvent.kt index 1d8efb3211..811b39d24d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/notifications/MemberRemovedNotificationEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/notifications/MemberRemovedNotificationEvent.kt @@ -49,6 +49,16 @@ class MemberRemovedNotificationEvent( /** The channel UUID the member was removed from - the `h` tag. */ fun channel() = tags.notificationChannel() + /** + * The relay-reported body: who performed the change ([MembershipNotificationContent.actor]) and the + * channel it applies to. The relay emits this kind for a self-join too, so the actor is what + * separates "I joined" from "somebody added me" — see [MembershipNotificationContent]. + */ + fun notification() = MembershipNotificationContent.parse(content) + + /** The pubkey that performed the add/remove, or null when the body is missing or malformed. */ + fun actor() = notification()?.actor + companion object { const val KIND = 44101 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/notifications/MembershipNotificationContent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/notifications/MembershipNotificationContent.kt new file mode 100644 index 0000000000..7d57842f0d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/notifications/MembershipNotificationContent.kt @@ -0,0 +1,81 @@ +/* + * 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.quartz.buzz.notifications + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.utils.Log + +/** + * The JSON body a Buzz relay puts on a kind-44100/44101 membership notification: + * + * ```json + * {"type":"member_added","channel_id":"","actor":""} + * ``` + * + * Ground truth: `emit_membership_notification` in Buzz's `buzz-relay/src/handlers/side_effects.rs`. + * + * [actor] is the decisive field. The relay emits the *same* kind whether somebody added you or you + * joined yourself — a self-join (kind 9021) is reported with `actor == target`. Without reading it, + * "I joined this" and "a stranger put me in this" are indistinguishable, which is what let channels + * appear silently. + */ +class MembershipNotificationContent( + val type: String?, + val channelId: String?, + val actor: HexKey?, +) { + /** True when this records somebody *else* adding [viewer] — i.e. it needs the viewer's consent. */ + fun addedBySomeoneElse(viewer: HexKey): Boolean = actor != null && !actor.equals(viewer, ignoreCase = true) + + companion object { + const val TYPE_MEMBER_ADDED = "member_added" + const val TYPE_MEMBER_REMOVED = "member_removed" + + private val TYPE = Regex("\"type\"\\s*:\\s*\"([^\"]*)\"") + private val CHANNEL_ID = Regex("\"channel_id\"\\s*:\\s*\"([^\"]*)\"") + private val ACTOR = Regex("\"actor\"\\s*:\\s*\"([0-9a-fA-F]{64})\"") + + /** + * Parses the notification body, or null when [content] is blank/unparseable. Regex rather than a + * JSON parse because this runs on the consume path for every membership notification and the + * body is a fixed three-field object written by the relay; a malformed one must degrade to + * "unknown actor" (treated as needing consent) rather than throw. + */ + fun parse(content: String): MembershipNotificationContent? { + if (content.isBlank()) return null + return try { + MembershipNotificationContent( + type = TYPE.find(content)?.groupValues?.get(1), + channelId = CHANNEL_ID.find(content)?.groupValues?.get(1), + actor = + ACTOR + .find(content) + ?.groupValues + ?.get(1) + ?.lowercase(), + ) + } catch (e: Exception) { + Log.w("MembershipNotification") { "Could not parse membership notification: ${e.message}" } + null + } + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/stream/SystemMessagePayload.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/stream/SystemMessagePayload.kt index 4d7021dcd7..7bdcdf8333 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/stream/SystemMessagePayload.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/stream/SystemMessagePayload.kt @@ -31,11 +31,26 @@ import kotlinx.serialization.json.Json * record of a channel state change (join, leave, rename, archive, delete, ...). * * Field names mirror the `serde_json::json!` payloads emitted by `emit_system_message` - * and its callers in Buzz's `buzz-relay/src/handlers/side_effects.rs`. Only [type] is - * always present; [actor] is present on almost every variant, and the remaining fields - * are variant-specific (`member_joined`/`member_removed` carry [target], `topic_changed` - * carries [topic], `ttl_changed` carries [ttlSeconds], etc). Unknown keys are ignored - * for forward compatibility. + * and its callers in Buzz's `crates/buzz-relay/src/handlers/`. Only [type] is always + * present; [actor] is present on every variant the relay emits today, and the rest are + * variant-specific. Unknown keys are ignored for forward compatibility, so a relay that + * grows a new field or a new [type] degrades to a plain line instead of failing to parse. + * + * The complete vocabulary, read off `side_effects.rs` / `command_executor.rs`: + * + * | [type] | carries | emitted when | + * |--|--|--| + * | [MEMBER_JOINED] | [actor], [target] | someone was added ([actor] added [target]) or joined on their own ([actor] == [target]) | + * | [MEMBER_LEFT] | [actor], optional [target] | a member left; the explicit-leave path omits [target] | + * | [MEMBER_REMOVED] | [actor], [target] | [actor] removed [target] | + * | [TOPIC_CHANGED] | [actor], [topic] | the channel topic was set | + * | [PURPOSE_CHANGED] | [actor], [purpose] | the channel purpose was set | + * | [VISIBILITY_CHANGED] | [actor], [visibility] | flipped between [VISIBILITY_OPEN] and [VISIBILITY_PRIVATE] | + * | [TTL_CHANGED] | [actor], [ttlSeconds] | disappearing messages set; `null` [ttlSeconds] means cleared (permanent) | + * | [CHANNEL_ARCHIVED] / [CHANNEL_UNARCHIVED] | [actor] | archive flag flipped | + * | [CHANNEL_CREATED] / [CHANNEL_DELETED] | [actor] | channel lifecycle | + * | [MESSAGE_DELETED] | [actor], [targetEventId], optional [actionId] / [reasonCode] / [publicReason] | a message was deleted (moderation tombstone) | + * | [DM_CREATED] | [actor], [participants] | a DM channel was opened | */ @Serializable data class SystemMessagePayload( @@ -46,9 +61,29 @@ data class SystemMessagePayload( val purpose: String? = null, val visibility: String? = null, @SerialName("ttl_seconds") val ttlSeconds: Long? = null, + /** The deleted message's id on [MESSAGE_DELETED]. */ + @SerialName("target_event_id") val targetEventId: String? = null, + /** Moderation-action id linking the tombstone back to the action that caused it. */ + @SerialName("action_id") val actionId: String? = null, + /** Machine-readable moderation reason (e.g. `spam`), when the deleter gave one. */ + @SerialName("reason_code") val reasonCode: String? = null, + /** Human-readable moderation reason the relay is willing to show everyone. */ + @SerialName("public_reason") val publicReason: String? = null, + /** Every participant of a newly opened DM, [actor] included. */ + val participants: List? = null, ) { fun encodeToJson(): String = JSON.encodeToString(this) + /** + * The pubkey the sentence is *about* — whose avatar to show. Membership changes are about the + * member who joined/left/was removed; everything else is about whoever performed the action. + */ + fun subject(): String? = + when (type) { + MEMBER_JOINED, MEMBER_LEFT, MEMBER_REMOVED -> target ?: actor + else -> actor + } + companion object { val JSON = Json { @@ -58,5 +93,25 @@ data class SystemMessagePayload( } fun decodeFromJson(json: String): SystemMessagePayload = JSON.decodeFromString(json) + + const val MEMBER_JOINED = "member_joined" + const val MEMBER_LEFT = "member_left" + const val MEMBER_REMOVED = "member_removed" + const val TOPIC_CHANGED = "topic_changed" + const val PURPOSE_CHANGED = "purpose_changed" + const val VISIBILITY_CHANGED = "visibility_changed" + const val TTL_CHANGED = "ttl_changed" + const val CHANNEL_ARCHIVED = "channel_archived" + const val CHANNEL_UNARCHIVED = "channel_unarchived" + const val CHANNEL_CREATED = "channel_created" + const val CHANNEL_DELETED = "channel_deleted" + const val MESSAGE_DELETED = "message_deleted" + const val DM_CREATED = "dm_created" + + /** Searchable, anyone can join. */ + const val VISIBILITY_OPEN = "open" + + /** Hidden, invite-only. */ + const val VISIBILITY_PRIVATE = "private" } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt index a7904e51aa..a14b0916c5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt @@ -294,6 +294,8 @@ import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRejectEvent import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent @@ -303,8 +305,6 @@ import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent -import com.vitorpamplona.quartz.nipXXBolt12Zaps.offer.Bolt12OfferListEvent -import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent @@ -541,8 +541,8 @@ object KindNames { RelayAddMemberEvent.KIND to KindName("Relay Add Member", "43"), RelayRemoveMemberEvent.KIND to KindName("Relay Remove Member", "43"), OnchainZapEvent.KIND to KindName("Onchain Zap", "BC"), - Bolt12ZapEvent.KIND to KindName("Bolt12 Zap", "XX"), - Bolt12OfferListEvent.KIND to KindName("Bolt12 Offers", "XX"), + Bolt12ZapEvent.KIND to KindName("Bolt12 Zap", "B1"), + Bolt12OfferListEvent.KIND to KindName("Bolt12 Offers", "B1"), PutUserEvent.KIND to KindName("Group Put User", "29"), RemoveUserEvent.KIND to KindName("Group Remove User", "29"), EditMetadataEvent.KIND to KindName("Group Edit Metadata", "29"), diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupMetadataEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupMetadataEvent.kt index b708ca5b24..a05f2d7b9f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupMetadataEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupMetadataEvent.kt @@ -70,6 +70,25 @@ class GroupMetadataEvent( */ fun geohashes() = tags.geohashes() + /** + * The Buzz channel type, carried as a `t` tag alongside the NIP-29 metadata: + * `stream` (linear chat, the default), `forum`, `dm`, or `workflow`. Ground truth: + * `ChannelType` in Buzz's `buzz-core/src/channel.rs`, emitted by the relay in + * `side_effects.rs` as `["t", channel_type]`. + * + * Read through this rather than [hashtags], which returns every `t` tag: on a Buzz relay the + * type shares the tag name with real hashtags, so only a value in the known set is a type. + * Returns null on a vanilla NIP-29 relay, which has no such concept. + */ + fun buzzChannelType(): String? = tags.firstNotNullOfOrNull { if (it.size > 1 && it[0] == "t" && it[1] in BUZZ_CHANNEL_TYPES) it[1] else null } + + /** + * True when this channel is a Buzz direct-message conversation rather than a named channel. The + * distinction decides whether a kind-44100 "you were added" belongs in the DM list or needs the + * viewer's consent before it shows up on Messages. + */ + fun isBuzzDmChannel() = buzzChannelType() == BUZZ_CHANNEL_TYPE_DM + /** Only members can read. Presence of the `private` flag; absent = public read. */ fun isPrivate() = tags.hasTagName("private") @@ -134,6 +153,11 @@ class GroupMetadataEvent( } companion object { + const val BUZZ_CHANNEL_TYPE_DM = "dm" + + /** Every value Buzz's `ChannelType` can serialize to. */ + val BUZZ_CHANNEL_TYPES = setOf("stream", "forum", BUZZ_CHANNEL_TYPE_DM, "workflow") + const val KIND = 39000 fun build( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/Request.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/Request.kt index dfe5031ff3..e4b42fcabc 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/Request.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/Request.kt @@ -51,7 +51,7 @@ class PayInvoiceMethod( // (bolt11 `lightning=`, BOLT12 `lno=`, or on-chain). `payment` is the BIP321 URI; // `amount` (msats) is required only when the instruction has no amount; `payer_note` // is delivered to the payee when the instruction supports payer messages (for a -// BOLT12 zap this carries `nostr:nipXX:`). +// BOLT12 zap this carries `nostr:nipB1:`). class PayParams( var payment: String? = null, var amount: Long? = null, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12Bech32.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/bolt12/Bolt12Bech32.kt similarity index 98% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12Bech32.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/bolt12/Bolt12Bech32.kt index a4395a0f57..7b7fe10414 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12Bech32.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/bolt12/Bolt12Bech32.kt @@ -18,7 +18,7 @@ * 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.quartz.nipXXBolt12Zaps.bolt12 +package com.vitorpamplona.quartz.nipB1Bolt12Zaps.bolt12 import com.vitorpamplona.quartz.nip19Bech32.bech32.Bech32 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12Merkle.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/bolt12/Bolt12Merkle.kt similarity index 99% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12Merkle.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/bolt12/Bolt12Merkle.kt index 5432ca13cc..f6a81a22c1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12Merkle.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/bolt12/Bolt12Merkle.kt @@ -18,7 +18,7 @@ * 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.quartz.nipXXBolt12Zaps.bolt12 +package com.vitorpamplona.quartz.nipB1Bolt12Zaps.bolt12 import com.vitorpamplona.quartz.utils.sha256.sha256 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12Offer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/bolt12/Bolt12Offer.kt similarity index 95% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12Offer.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/bolt12/Bolt12Offer.kt index 535a6a2d21..cdb6ada2c8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12Offer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/bolt12/Bolt12Offer.kt @@ -18,10 +18,10 @@ * 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.quartz.nipXXBolt12Zaps.bolt12 +package com.vitorpamplona.quartz.nipB1Bolt12Zaps.bolt12 /** - * A parsed BOLT12 offer (`lno1...`). Exposes the fields NIP-XX cares about; the + * A parsed BOLT12 offer (`lno1...`). Exposes the fields NIP-B1 cares about; the * full TLV stream is retained for callers that need more. * * See https://github.com/lightning/bolts/blob/master/12-offer-encoding.md#offer-fields diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12PayerProof.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/bolt12/Bolt12PayerProof.kt similarity index 98% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12PayerProof.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/bolt12/Bolt12PayerProof.kt index 3cbf2f0fa2..772201f00c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12PayerProof.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/bolt12/Bolt12PayerProof.kt @@ -18,7 +18,7 @@ * 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.quartz.nipXXBolt12Zaps.bolt12 +package com.vitorpamplona.quartz.nipB1Bolt12Zaps.bolt12 /** * A parsed BOLT12 payer proof (`lnp1...`), per lightning/bolts#1346. @@ -108,7 +108,7 @@ class Bolt12PayerProof( /** * True when every field the BOLT12 crypto verification requires is present and * well-sized (lightning/bolts#1346 reader rules). `invreq_payer_note` is *not* - * required here — the NIP-XX zap binding checks it separately in the validator. + * required here — the NIP-B1 zap binding checks it separately in the validator. */ fun hasAllCryptoFields(): Boolean = invreqPayerId() != null && diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12ProofBuilder.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/bolt12/Bolt12ProofBuilder.kt similarity index 99% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12ProofBuilder.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/bolt12/Bolt12ProofBuilder.kt index e4b5548703..643a52fd78 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12ProofBuilder.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/bolt12/Bolt12ProofBuilder.kt @@ -18,7 +18,7 @@ * 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.quartz.nipXXBolt12Zaps.bolt12 +package com.vitorpamplona.quartz.nipB1Bolt12Zaps.bolt12 /** * Mints a canonical BOLT12 `lnp1...` payer proof from a full invoice, per diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Tlv.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/bolt12/Tlv.kt similarity index 99% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Tlv.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/bolt12/Tlv.kt index 7d166e4980..954b6405d3 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Tlv.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/bolt12/Tlv.kt @@ -18,7 +18,7 @@ * 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.quartz.nipXXBolt12Zaps.bolt12 +package com.vitorpamplona.quartz.nipB1Bolt12Zaps.bolt12 /** * BOLT-1 BigSize codec: a variable-length unsigned integer, big-endian, with a diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/builder/Bolt12ZapBuilder.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/builder/Bolt12ZapBuilder.kt similarity index 93% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/builder/Bolt12ZapBuilder.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/builder/Bolt12ZapBuilder.kt index a829e6ca6e..1b34cc6d30 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/builder/Bolt12ZapBuilder.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/builder/Bolt12ZapBuilder.kt @@ -18,21 +18,21 @@ * 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.quartz.nipXXBolt12Zaps.builder +package com.vitorpamplona.quartz.nipB1Bolt12Zaps.builder import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nipXXBolt12Zaps.intent.Bolt12ZapIntentEvent -import com.vitorpamplona.quartz.nipXXBolt12Zaps.verify.Bolt12ZapValidator -import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.intent.Bolt12ZapIntentEvent +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.verify.Bolt12ZapValidator +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.TimeUtils /** - * Assembles the two NIP-XX events in the order the payment flow requires: + * Assembles the two NIP-B1 events in the order the payment flow requires: * * 1. [buildIntent] — sign a kind 9737 zap intent *before* paying. * 2. Pay the offer, putting [payerNote] in the BOLT12 `invreq_payer_note`, and diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/intent/Bolt12ZapIntentEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/intent/Bolt12ZapIntentEvent.kt similarity index 94% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/intent/Bolt12ZapIntentEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/intent/Bolt12ZapIntentEvent.kt index ab5962dec4..675d28a40b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/intent/Bolt12ZapIntentEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/intent/Bolt12ZapIntentEvent.kt @@ -18,7 +18,7 @@ * 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.quartz.nipXXBolt12Zaps.intent +package com.vitorpamplona.quartz.nipB1Bolt12Zaps.intent import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent @@ -36,18 +36,18 @@ import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.events.toETag import com.vitorpamplona.quartz.nip01Core.tags.kinds.KindTag import com.vitorpamplona.quartz.nip01Core.tags.people.PTag -import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.AmountTag -import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.OfferTag -import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.ZapIdTag +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.tags.AmountTag +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.tags.OfferTag +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.tags.ZapIdTag import com.vitorpamplona.quartz.utils.TimeUtils /** - * NIP-XX: BOLT12 Zaps — the **zap intent** (kind 9737). + * NIP-B1: BOLT12 Zaps — the **zap intent** (kind 9737). * * The payer creates and signs this event *before* paying the BOLT12 offer. It * binds the payer's Nostr key to the recipient, the target, the amount and the * offer. The payer then references this event's id in the BOLT12 - * `invreq_payer_note` (`nostr:nipXX:`), and finally embeds + * `invreq_payer_note` (`nostr:nipB1:`), and finally embeds * the whole serialized intent inside the kind 9736 zap event's `description` tag * — the same embedding pattern NIP-57 uses for the zap request. * diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/intent/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/intent/TagArrayBuilderExt.kt similarity index 90% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/intent/TagArrayBuilderExt.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/intent/TagArrayBuilderExt.kt index ff8109009c..bcecd4b44f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/intent/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/intent/TagArrayBuilderExt.kt @@ -18,7 +18,7 @@ * 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.quartz.nipXXBolt12Zaps.intent +package com.vitorpamplona.quartz.nipB1Bolt12Zaps.intent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder @@ -26,9 +26,9 @@ import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.kinds.KindTag import com.vitorpamplona.quartz.nip01Core.tags.people.PTag -import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.AmountTag -import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.OfferTag -import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.ZapIdTag +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.tags.AmountTag +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.tags.OfferTag +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.tags.ZapIdTag fun TagArrayBuilder.recipient(recipientPubKey: HexKey) = addUnique(PTag.assemble(recipientPubKey, null)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/offer/Bolt12OfferListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/offer/Bolt12OfferListEvent.kt similarity index 95% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/offer/Bolt12OfferListEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/offer/Bolt12OfferListEvent.kt index 3c16edb97d..e1c4c17191 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/offer/Bolt12OfferListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/offer/Bolt12OfferListEvent.kt @@ -18,7 +18,7 @@ * 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.quartz.nipXXBolt12Zaps.offer +package com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Address @@ -27,11 +27,11 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag -import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.OfferTag +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.tags.OfferTag import com.vitorpamplona.quartz.utils.TimeUtils /** - * NIP-XX: BOLT12 Zaps — a user's **BOLT12 offer list** (kind 10058). + * NIP-B1: BOLT12 Zaps — a user's **BOLT12 offer list** (kind 10058). * * A replaceable event, anchored to the author's pubkey, that publishes one or more * canonical raw BOLT12 offers (`["offer", "lno1..."]`). This is how a payer diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/tags/AmountTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/tags/AmountTag.kt similarity index 94% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/tags/AmountTag.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/tags/AmountTag.kt index f3d762a207..e3b9be906c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/tags/AmountTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/tags/AmountTag.kt @@ -18,13 +18,13 @@ * 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.quartz.nipXXBolt12Zaps.tags +package com.vitorpamplona.quartz.nipB1Bolt12Zaps.tags import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.utils.ensure /** - * The `amount` tag of a NIP-XX BOLT12 zap or zap intent: the payment amount in + * The `amount` tag of a NIP-B1 BOLT12 zap or zap intent: the payment amount in * **millisatoshis** (not sats — this matches the BOLT12 `invoice_amount` field it * is validated against, and NIP-57's `amount` tag). */ diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/tags/DescriptionTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/tags/DescriptionTag.kt similarity index 97% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/tags/DescriptionTag.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/tags/DescriptionTag.kt index 611dfac3b2..a9aacdfda0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/tags/DescriptionTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/tags/DescriptionTag.kt @@ -18,7 +18,7 @@ * 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.quartz.nipXXBolt12Zaps.tags +package com.vitorpamplona.quartz.nipB1Bolt12Zaps.tags import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.utils.ensure diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/tags/OfferTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/tags/OfferTag.kt similarity index 94% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/tags/OfferTag.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/tags/OfferTag.kt index 19f24595e5..7005d7be5e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/tags/OfferTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/tags/OfferTag.kt @@ -18,10 +18,10 @@ * 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.quartz.nipXXBolt12Zaps.tags +package com.vitorpamplona.quartz.nipB1Bolt12Zaps.tags import com.vitorpamplona.quartz.nip01Core.core.has -import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Bech32 +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.bolt12.Bolt12Bech32 import com.vitorpamplona.quartz.utils.ensure /** diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/tags/PayerTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/tags/PayerTag.kt similarity index 97% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/tags/PayerTag.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/tags/PayerTag.kt index 22749bb829..e0f01a4910 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/tags/PayerTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/tags/PayerTag.kt @@ -18,7 +18,7 @@ * 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.quartz.nipXXBolt12Zaps.tags +package com.vitorpamplona.quartz.nipB1Bolt12Zaps.tags import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.has diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/tags/ProofTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/tags/ProofTag.kt similarity index 94% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/tags/ProofTag.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/tags/ProofTag.kt index cc0d88d22c..d4d8fd5069 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/tags/ProofTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/tags/ProofTag.kt @@ -18,10 +18,10 @@ * 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.quartz.nipXXBolt12Zaps.tags +package com.vitorpamplona.quartz.nipB1Bolt12Zaps.tags import com.vitorpamplona.quartz.nip01Core.core.has -import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Bech32 +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.bolt12.Bolt12Bech32 import com.vitorpamplona.quartz.utils.ensure /** diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/tags/ZapIdTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/tags/ZapIdTag.kt similarity index 97% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/tags/ZapIdTag.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/tags/ZapIdTag.kt index f556908a16..125784e609 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/tags/ZapIdTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/tags/ZapIdTag.kt @@ -18,7 +18,7 @@ * 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.quartz.nipXXBolt12Zaps.tags +package com.vitorpamplona.quartz.nipB1Bolt12Zaps.tags import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.utils.Hex diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ProofResult.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/verify/Bolt12ProofResult.kt similarity index 97% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ProofResult.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/verify/Bolt12ProofResult.kt index 73cbaa06fe..bd0dfadfe7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ProofResult.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/verify/Bolt12ProofResult.kt @@ -18,7 +18,7 @@ * 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.quartz.nipXXBolt12Zaps.verify +package com.vitorpamplona.quartz.nipB1Bolt12Zaps.verify import androidx.compose.runtime.Immutable diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ProofVerifier.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/verify/Bolt12ProofVerifier.kt similarity index 97% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ProofVerifier.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/verify/Bolt12ProofVerifier.kt index b06b8ac7d1..f18319aee2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ProofVerifier.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/verify/Bolt12ProofVerifier.kt @@ -18,12 +18,12 @@ * 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.quartz.nipXXBolt12Zaps.verify +package com.vitorpamplona.quartz.nipB1Bolt12Zaps.verify import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto -import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Merkle -import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12PayerProof -import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.TlvRecord +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.bolt12.Bolt12Merkle +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.bolt12.Bolt12PayerProof +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.bolt12.TlvRecord import com.vitorpamplona.quartz.utils.sha256.sha256 /** diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ZapValidation.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/verify/Bolt12ZapValidation.kt similarity index 97% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ZapValidation.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/verify/Bolt12ZapValidation.kt index c0809e0d10..764f835072 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ZapValidation.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/verify/Bolt12ZapValidation.kt @@ -18,12 +18,12 @@ * 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.quartz.nipXXBolt12Zaps.verify +package com.vitorpamplona.quartz.nipB1Bolt12Zaps.verify import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.HexKey -/** Result of validating a NIP-XX BOLT12 zap event (kind 9736). */ +/** Result of validating a NIP-B1 BOLT12 zap event (kind 9736). */ @Immutable sealed interface Bolt12ZapValidation { /** diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ZapValidator.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/verify/Bolt12ZapValidator.kt similarity index 95% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ZapValidator.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/verify/Bolt12ZapValidator.kt index 8a5164efec..372753da2f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ZapValidator.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/verify/Bolt12ZapValidator.kt @@ -18,20 +18,20 @@ * 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.quartz.nipXXBolt12Zaps.verify +package com.vitorpamplona.quartz.nipB1Bolt12Zaps.verify import com.vitorpamplona.quartz.nip01Core.crypto.verify import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.people.PTag -import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Offer -import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12PayerProof -import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.DescriptionTag -import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.bolt12.Bolt12Offer +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.bolt12.Bolt12PayerProof +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.tags.DescriptionTag +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.utils.Hex /** - * NIP-XX validator: runs the spec's validation steps over a [Bolt12ZapEvent] and + * NIP-B1 validator: runs the spec's validation steps over a [Bolt12ZapEvent] and * returns a [Bolt12ZapValidation]. A client MUST validate an event with this (and * deduplicate the results by [Bolt12ZapValidation.Valid.paymentHashHex]) before * counting a BOLT12 zap. @@ -47,7 +47,7 @@ import com.vitorpamplona.quartz.utils.Hex * 5. the raw BOLT12 offer parses; * 6. the `lnp` payer proof decodes and its crypto checks pass (see * [Bolt12ProofVerifier]); - * 7. the proof binds to this zap: `invreq_payer_note == nostr:nipXX:`, + * 7. the proof binds to this zap: `invreq_payer_note == nostr:nipB1:`, * `invoice_amount == amount`, and the proof matches the offer. */ class Bolt12ZapValidator( @@ -236,9 +236,9 @@ class Bolt12ZapValidator( /** * The NIP binds the Lightning payment to the signed intent through the * BOLT12 `invreq_payer_note`, which MUST equal this prefix followed by the - * intent event id. The `nipXX` segment tracks the final NIP number. + * intent event id. The `nipB1` segment is the assigned NIP-B1 number. */ - const val NIP_URI_PREFIX = "nostr:nipXX:" + const val NIP_URI_PREFIX = "nostr:nipB1:" } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/zap/Bolt12ZapEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/zap/Bolt12ZapEvent.kt similarity index 93% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/zap/Bolt12ZapEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/zap/Bolt12ZapEvent.kt index 50b4874e13..8f82acb27f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/zap/Bolt12ZapEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/zap/Bolt12ZapEvent.kt @@ -18,7 +18,7 @@ * 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.quartz.nipXXBolt12Zaps.zap +package com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event @@ -34,17 +34,17 @@ import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.kinds.KindTag import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip50Search.SearchableEvent -import com.vitorpamplona.quartz.nipXXBolt12Zaps.intent.Bolt12ZapIntentEvent -import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.AmountTag -import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.DescriptionTag -import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.OfferTag -import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.PayerTag -import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.ProofTag +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.intent.Bolt12ZapIntentEvent +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.tags.AmountTag +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.tags.DescriptionTag +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.tags.OfferTag +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.tags.PayerTag +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.tags.ProofTag import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils /** - * NIP-XX: BOLT12 Zaps — the **zap event** (kind 9736). + * NIP-B1: BOLT12 Zaps — the **zap event** (kind 9736). * * A public, self-verifying proof that a BOLT12 payment was made to the author of * a profile, event, or addressable event. It carries: diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/zap/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/zap/TagArrayBuilderExt.kt similarity index 87% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/zap/TagArrayBuilderExt.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/zap/TagArrayBuilderExt.kt index d442fec654..2aa46f9286 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/zap/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/zap/TagArrayBuilderExt.kt @@ -18,7 +18,7 @@ * 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.quartz.nipXXBolt12Zaps.zap +package com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder @@ -26,11 +26,11 @@ import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.kinds.KindTag import com.vitorpamplona.quartz.nip01Core.tags.people.PTag -import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.AmountTag -import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.DescriptionTag -import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.OfferTag -import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.PayerTag -import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.ProofTag +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.tags.AmountTag +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.tags.DescriptionTag +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.tags.OfferTag +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.tags.PayerTag +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.tags.ProofTag fun TagArrayBuilder.description(serializedIntentEventJson: String) = addUnique(DescriptionTag.assemble(serializedIntentEventJson)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 459d981981..622fa0b889 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.buzz.amTurnMetrics.AgentTurnMetricEvent import com.vitorpamplona.quartz.buzz.aoObserver.ObserverFrameEvent import com.vitorpamplona.quartz.buzz.apPersonas.PersonaEvent import com.vitorpamplona.quartz.buzz.audit.AuditEntryEvent +import com.vitorpamplona.quartz.buzz.cwChannelWindow.ThreadSummaryEvent import com.vitorpamplona.quartz.buzz.cwChannelWindow.WindowBoundsEvent import com.vitorpamplona.quartz.buzz.dm.DmAddMemberEvent import com.vitorpamplona.quartz.buzz.dm.DmCreatedEvent @@ -193,6 +194,7 @@ import com.vitorpamplona.quartz.nip29RelayGroups.moderation.RemoveUserEvent import com.vitorpamplona.quartz.nip29RelayGroups.moderation.UpdatePinListEvent import com.vitorpamplona.quartz.nip29RelayGroups.request.JoinRequestEvent import com.vitorpamplona.quartz.nip29RelayGroups.request.LeaveRequestEvent +import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent import com.vitorpamplona.quartz.nip32Labeling.LabelEvent @@ -386,6 +388,9 @@ import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRejectEvent import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.intent.Bolt12ZapIntentEvent +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent @@ -395,9 +400,6 @@ import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent -import com.vitorpamplona.quartz.nipXXBolt12Zaps.intent.Bolt12ZapIntentEvent -import com.vitorpamplona.quartz.nipXXBolt12Zaps.offer.Bolt12OfferListEvent -import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent @@ -559,7 +561,25 @@ class EventFactory { GroupMembersEvent.KIND -> GroupMembersEvent(id, pubKey, createdAt, tags, content, sig) SupportedRolesEvent.KIND -> SupportedRolesEvent(id, pubKey, createdAt, tags, content, sig) GroupParticipantsEvent.KIND -> GroupParticipantsEvent(id, pubKey, createdAt, tags, content, sig) - GroupPinnedEvent.KIND -> GroupPinnedEvent(id, pubKey, createdAt, tags, content, sig) + // kind:39005 is shared by two relay-signed, addressable events that never coexist on + // one relay: NIP-29's group pin list (relay29 family) and Buzz's NIP-CW thread + // summary. Disambiguate by Buzz's required `h` (channel) tag — a thread summary is + // addressed by `d` = the thread ROOT EVENT ID and also carries `e` = that root and + // `h` = the channel, while a pin list is addressed by `d` = the GROUP ID with the + // pinned ids as `e` tags and no `h` at all. + // + // Without this, a Buzz thread summary parses as a GroupPinnedEvent — silently, since + // nothing throws — and `pinnedEventIds()` would read the summary's `e` tag and report + // the thread root as a pinned message. Buzz publishes these live to channel + // subscribers (`side_effects.rs`, Redis fan-out to `EventTopic::Channel`), not only on + // its HTTP bridge, so any future channel-scoped filter that asks for 39005 receives + // them. Buzz's own pinned message is a different kind entirely (40004). + GroupPinnedEvent.KIND -> + if (tags.any { it.size > 1 && it[0] == GroupIdTag.TAG_NAME }) { + ThreadSummaryEvent(id, pubKey, createdAt, tags, content, sig) + } else { + GroupPinnedEvent(id, pubKey, createdAt, tags, content, sig) + } UpdatePinListEvent.KIND -> UpdatePinListEvent(id, pubKey, createdAt, tags, content, sig) ChessGameEvent.KIND -> ChessGameEvent(id, pubKey, createdAt, tags, content, sig) CodeSnippetEvent.KIND -> CodeSnippetEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/stream/SystemMessageEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/stream/SystemMessageEventTest.kt index dd1c247b03..9f7284f6b7 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/stream/SystemMessageEventTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/stream/SystemMessageEventTest.kt @@ -69,4 +69,77 @@ class SystemMessageEventTest { assertEquals(actor, payload.actor) assertEquals(3600L, payload.ttlSeconds) } + + /** + * Every variant the relay emits, with the exact JSON shape from `emit_system_message`'s callers + * in `crates/buzz-relay/src/handlers/`. The UI words each one differently — "was added by" vs + * "joined", "made this private" vs a raw token — so a field silently dropping to null here is a + * sentence losing its subject on screen. + */ + @Test + fun everyRelayVariantKeepsItsFields() { + fun decode(json: String) = SystemMessagePayload.decodeFromJson(json) + + val added = decode("""{"type":"member_joined","actor":"$actor","target":"$target"}""") + assertEquals(SystemMessagePayload.MEMBER_JOINED, added.type) + assertEquals(target, added.target) + + // The self-join path sends the SAME type with actor == target; that equality is the only + // thing separating "Bob joined" from "Bob was added by Alice". + val selfJoin = decode("""{"type":"member_joined","actor":"$actor","target":"$actor"}""") + assertEquals(selfJoin.actor, selfJoin.target) + + // The explicit-leave path omits `target` entirely. + val left = decode("""{"type":"member_left","actor":"$actor"}""") + assertEquals(null, left.target) + + val removed = decode("""{"type":"member_removed","actor":"$actor","target":"$target"}""") + assertEquals(target, removed.target) + + assertEquals("Standup", decode("""{"type":"topic_changed","actor":"$actor","topic":"Standup"}""").topic) + assertEquals("Ship it", decode("""{"type":"purpose_changed","actor":"$actor","purpose":"Ship it"}""").purpose) + + val private = decode("""{"type":"visibility_changed","actor":"$actor","visibility":"private"}""") + assertEquals(SystemMessagePayload.VISIBILITY_PRIVATE, private.visibility) + val open = decode("""{"type":"visibility_changed","actor":"$actor","visibility":"open"}""") + assertEquals(SystemMessagePayload.VISIBILITY_OPEN, open.visibility) + + // Clearing the TTL sends an explicit null, which must read as "no TTL", not as a parse failure. + assertEquals(null, decode("""{"type":"ttl_changed","actor":"$actor","ttl_seconds":null}""").ttlSeconds) + assertEquals(604800L, decode("""{"type":"ttl_changed","actor":"$actor","ttl_seconds":604800}""").ttlSeconds) + + for (type in listOf("channel_archived", "channel_unarchived", "channel_created", "channel_deleted")) { + assertEquals(actor, decode("""{"type":"$type","actor":"$actor"}""").actor) + } + + val deleted = + decode( + """{"type":"message_deleted","actor":"$actor","target_event_id":"cc","action_id":"a1","reason_code":"spam","public_reason":"off topic"}""", + ) + assertEquals("cc", deleted.targetEventId) + assertEquals("a1", deleted.actionId) + assertEquals("spam", deleted.reasonCode) + assertEquals("off topic", deleted.publicReason) + + val dm = decode("""{"type":"dm_created","actor":"$actor","participants":["$actor","$target"]}""") + assertEquals(listOf(actor, target), dm.participants) + } + + /** The avatar shown beside the line: the member a membership change is about, the actor otherwise. */ + @Test + fun subjectIsTheMemberForMembershipChangesAndTheActorOtherwise() { + assertEquals(target, SystemMessagePayload(type = SystemMessagePayload.MEMBER_JOINED, actor = actor, target = target).subject()) + assertEquals(target, SystemMessagePayload(type = SystemMessagePayload.MEMBER_REMOVED, actor = actor, target = target).subject()) + assertEquals(actor, SystemMessagePayload(type = SystemMessagePayload.MEMBER_LEFT, actor = actor).subject()) + assertEquals(actor, SystemMessagePayload(type = SystemMessagePayload.VISIBILITY_CHANGED, actor = actor, visibility = "open").subject()) + assertEquals(actor, SystemMessagePayload(type = SystemMessagePayload.TOPIC_CHANGED, actor = actor, topic = "x").subject()) + } + + /** An unknown type or extra key must survive as a line, not blow up the whole message. */ + @Test + fun unknownTypeAndUnknownKeysStillParse() { + val payload = SystemMessagePayload.decodeFromJson("""{"type":"pinned_changed","actor":"$actor","brand_new":"x"}""") + assertEquals("pinned_changed", payload.type) + assertEquals(actor, payload.actor) + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/PinEventsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/PinEventsTest.kt index 3cdd3fbe2d..b5ee8e80f3 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/PinEventsTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/PinEventsTest.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.quartz.nip29RelayGroups +import com.vitorpamplona.quartz.buzz.cwChannelWindow.ThreadSummaryContent +import com.vitorpamplona.quartz.buzz.cwChannelWindow.ThreadSummaryEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent import com.vitorpamplona.quartz.nip29RelayGroups.moderation.UpdatePinListEvent @@ -52,6 +54,39 @@ class PinEventsTest { assertEquals(listOf(id1, id2, id3), event.pinnedEventIds()) } + /** + * kind:39005 is shared with Buzz's [ThreadSummaryEvent]. A summary carries `h` (the channel); + * every NIP-29 relay-generated 39xxx event — this one included — is addressed by `d` alone and + * never emits `h`, which is what [EventFactory] discriminates on. Without it a summary parsed as + * a pin list and `pinnedEventIds()` reported the thread root as a pinned message. + */ + @Test + fun threadSummaryWithChannelTagDoesNotParseAsAPinList() { + val tags = arrayOf(arrayOf("d", id1), arrayOf("e", id1), arrayOf("h", gid)) + val content = ThreadSummaryContent(replyCount = 3, descendantCount = 5, lastReplyAt = 99).encodeToJson() + val event: Event = EventFactory.create("00".repeat(32), relaySelf, 100, ThreadSummaryEvent.KIND, tags, content, "22".repeat(64)) + + assertEquals(true, event is ThreadSummaryEvent) + event as ThreadSummaryEvent + assertEquals(id1, event.rootId()) + assertEquals(id1, event.rootEventTag()) + assertEquals(gid, event.channelId()) + assertEquals(3, event.summary().replyCount) + } + + /** Both classes' own builders keep that shape, so outbound signing routes back to the right class. */ + @Test + fun bothBuildersRoundTripThroughTheFactory() { + val pin = GroupPinnedEvent.build(gid, listOf(id1, id2)) + val summary = ThreadSummaryEvent.build(id1, gid, ThreadSummaryContent(replyCount = 1, descendantCount = 1)) + + val parsedPin: Event = EventFactory.create("00".repeat(32), relaySelf, 100, pin.kind, pin.tags, pin.content, "22".repeat(64)) + val parsedSummary: Event = EventFactory.create("00".repeat(32), relaySelf, 100, summary.kind, summary.tags, summary.content, "22".repeat(64)) + + assertEquals(true, parsedPin is GroupPinnedEvent) + assertEquals(true, parsedSummary is ThreadSummaryEvent) + } + @Test fun updatePinListEventParsesGroupAndIds() { val tags = arrayOf(arrayOf("h", gid), arrayOf("e", id1), arrayOf("e", id2)) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/RequestTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/RequestTest.kt index fe0b9ae39d..49d3f8d5fc 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/RequestTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/RequestTest.kt @@ -95,31 +95,31 @@ class RequestTest { @Test fun testPayCreateWithOfferAndPayerNote() { - val request = PayMethod.create("bitcoin:?lno=lno1abc", amount = 21_000L, payerNote = "nostr:nipXX:deadbeef") + val request = PayMethod.create("bitcoin:?lno=lno1abc", amount = 21_000L, payerNote = "nostr:nipB1:deadbeef") assertEquals(NwcMethod.PAY, request.method) assertEquals("bitcoin:?lno=lno1abc", request.params?.payment) assertEquals(21_000L, request.params?.amount) - assertEquals("nostr:nipXX:deadbeef", request.params?.payer_note) + assertEquals("nostr:nipB1:deadbeef", request.params?.payer_note) } @Test fun testPaySerialization() { - val request = PayMethod.create("bitcoin:?lno=lno1abc", amount = 21_000L, payerNote = "nostr:nipXX:deadbeef") + val request = PayMethod.create("bitcoin:?lno=lno1abc", amount = 21_000L, payerNote = "nostr:nipB1:deadbeef") val json = OptimizedJsonMapper.toJson(request) assertTrue(json.contains("\"method\":\"pay\"")) assertTrue(json.contains("\"payment\":\"bitcoin:?lno=lno1abc\"")) assertTrue(json.contains("\"amount\":21000")) - assertTrue(json.contains("\"payer_note\":\"nostr:nipXX:deadbeef\"")) + assertTrue(json.contains("\"payer_note\":\"nostr:nipB1:deadbeef\"")) } @Test fun testPayDeserialization() { - val json = """{"method":"pay","params":{"payment":"bitcoin:?lno=lno1abc","amount":21000,"payer_note":"nostr:nipXX:deadbeef"}}""" + val json = """{"method":"pay","params":{"payment":"bitcoin:?lno=lno1abc","amount":21000,"payer_note":"nostr:nipB1:deadbeef"}}""" val request = OptimizedJsonMapper.fromJsonTo(json) assertIs(request) assertEquals("bitcoin:?lno=lno1abc", request.params?.payment) assertEquals(21_000L, request.params?.amount) - assertEquals("nostr:nipXX:deadbeef", request.params?.payer_note) + assertEquals("nostr:nipB1:deadbeef", request.params?.payer_note) } @Test diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/Bolt12ZapEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/Bolt12ZapEventTest.kt similarity index 95% rename from quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/Bolt12ZapEventTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/Bolt12ZapEventTest.kt index c66cef592d..2561978a04 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/Bolt12ZapEventTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/Bolt12ZapEventTest.kt @@ -18,15 +18,15 @@ * 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.quartz.nipXXBolt12Zaps +package com.vitorpamplona.quartz.nipB1Bolt12Zaps import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal -import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Bech32 -import com.vitorpamplona.quartz.nipXXBolt12Zaps.intent.Bolt12ZapIntentEvent -import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.bolt12.Bolt12Bech32 +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.intent.Bolt12ZapIntentEvent +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12Bech32Test.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/bolt12/Bolt12Bech32Test.kt similarity index 98% rename from quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12Bech32Test.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/bolt12/Bolt12Bech32Test.kt index 11792873f3..f478c811dc 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12Bech32Test.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/bolt12/Bolt12Bech32Test.kt @@ -18,7 +18,7 @@ * 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.quartz.nipXXBolt12Zaps.bolt12 +package com.vitorpamplona.quartz.nipB1Bolt12Zaps.bolt12 import kotlin.test.Test import kotlin.test.assertContentEquals diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12MerkleTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/bolt12/Bolt12MerkleTest.kt similarity index 98% rename from quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12MerkleTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/bolt12/Bolt12MerkleTest.kt index c19f37e10e..f2fee1105e 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12MerkleTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/bolt12/Bolt12MerkleTest.kt @@ -18,7 +18,7 @@ * 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.quartz.nipXXBolt12Zaps.bolt12 +package com.vitorpamplona.quartz.nipB1Bolt12Zaps.bolt12 import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/TlvTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/bolt12/TlvTest.kt similarity index 99% rename from quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/TlvTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/bolt12/TlvTest.kt index 5308744b5c..fe5e3dd2a3 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/TlvTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/bolt12/TlvTest.kt @@ -18,7 +18,7 @@ * 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.quartz.nipXXBolt12Zaps.bolt12 +package com.vitorpamplona.quartz.nipB1Bolt12Zaps.bolt12 import com.vitorpamplona.quartz.utils.Hex import kotlin.test.Test diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/builder/Bolt12ZapBuilderTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/builder/Bolt12ZapBuilderTest.kt similarity index 94% rename from quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/builder/Bolt12ZapBuilderTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/builder/Bolt12ZapBuilderTest.kt index 1aa3d9862a..35fdd99bdb 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/builder/Bolt12ZapBuilderTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/builder/Bolt12ZapBuilderTest.kt @@ -18,13 +18,13 @@ * 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.quartz.nipXXBolt12Zaps.builder +package com.vitorpamplona.quartz.nipB1Bolt12Zaps.builder import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal -import com.vitorpamplona.quartz.nipXXBolt12Zaps.verify.Bolt12ProofFixture -import com.vitorpamplona.quartz.nipXXBolt12Zaps.verify.Bolt12ZapValidation -import com.vitorpamplona.quartz.nipXXBolt12Zaps.verify.Bolt12ZapValidator +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.verify.Bolt12ProofFixture +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.verify.Bolt12ZapValidation +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.verify.Bolt12ZapValidator import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/offer/Bolt12OfferListEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/offer/Bolt12OfferListEventTest.kt similarity index 96% rename from quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/offer/Bolt12OfferListEventTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/offer/Bolt12OfferListEventTest.kt index 104ce802e9..8f176984ce 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/offer/Bolt12OfferListEventTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/offer/Bolt12OfferListEventTest.kt @@ -18,12 +18,12 @@ * 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.quartz.nipXXBolt12Zaps.offer +package com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal -import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Bech32 +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.bolt12.Bolt12Bech32 import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12PayerProofVectorTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/verify/Bolt12PayerProofVectorTest.kt similarity index 97% rename from quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12PayerProofVectorTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/verify/Bolt12PayerProofVectorTest.kt index 449d4cc12e..8c8ffd6665 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12PayerProofVectorTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/verify/Bolt12PayerProofVectorTest.kt @@ -18,11 +18,11 @@ * 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.quartz.nipXXBolt12Zaps.verify +package com.vitorpamplona.quartz.nipB1Bolt12Zaps.verify import com.vitorpamplona.quartz.TestResourceLoader -import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12PayerProof -import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12ProofBuilder +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.bolt12.Bolt12PayerProof +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.bolt12.Bolt12ProofBuilder import com.vitorpamplona.quartz.utils.Hex import kotlinx.serialization.json.Json import kotlinx.serialization.json.jsonArray diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ProofFixture.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/verify/Bolt12ProofFixture.kt similarity index 91% rename from quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ProofFixture.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/verify/Bolt12ProofFixture.kt index e397c01877..8f4b2fd14a 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ProofFixture.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/verify/Bolt12ProofFixture.kt @@ -18,17 +18,17 @@ * 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.quartz.nipXXBolt12Zaps.verify +package com.vitorpamplona.quartz.nipB1Bolt12Zaps.verify import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto -import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Bech32 -import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Offer -import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12PayerProof -import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12ProofBuilder -import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Values -import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.TlvRecord -import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.TlvStream +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.bolt12.Bolt12Bech32 +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.bolt12.Bolt12Offer +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.bolt12.Bolt12PayerProof +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.bolt12.Bolt12ProofBuilder +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.bolt12.Bolt12Values +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.bolt12.TlvRecord +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.bolt12.TlvStream import com.vitorpamplona.quartz.utils.sha256.sha256 /** diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ZapValidatorTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/verify/Bolt12ZapValidatorTest.kt similarity index 98% rename from quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ZapValidatorTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/verify/Bolt12ZapValidatorTest.kt index 0d1d20ab50..56f5c0720f 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ZapValidatorTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/verify/Bolt12ZapValidatorTest.kt @@ -18,13 +18,13 @@ * 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.quartz.nipXXBolt12Zaps.verify +package com.vitorpamplona.quartz.nipB1Bolt12Zaps.verify import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal -import com.vitorpamplona.quartz.nipXXBolt12Zaps.intent.Bolt12ZapIntentEvent -import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.intent.Bolt12ZapIntentEvent +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.sha256.sha256 import kotlinx.coroutines.test.runTest