From f434e98b005f680f9f388df88894c23956fc74a7 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 26 Jul 2026 12:44:05 -0400 Subject: [PATCH 01/15] fix(nip29): scope group subscriptions per channel so Messages updates live MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Messages list showed a stale last message for every NIP-29 group while the open chat screen stayed live. The joined-group tail batched every group on a host relay into ONE filter carrying all their ids in `#h`. That is valid NIP-01 and relays answer it correctly for stored queries — which is what made it so confusing: the boot backfill populated every group, so the list looked right until it needed to change. `block/buzz` indexes each live subscription under a single channel uuid resolved from `#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, guarding against leaking private channel content to a subscriber whose membership was not checked per channel. So the batched tail backfilled at EOSE and then went permanently deaf. Measured on device: same relay, same connection, same subscription id, only the `#h` count changed — one value delivered live, two delivered nothing. The resolver scans every filter of a subscription, so splitting into one filter per group is not enough; each channel needs its own subscription. The EOSE managers are therefore keyed on GroupId, and the preloads mount one subscription per joined channel. Relays leave room for this: buzz allows max_subscriptions 1024 against max_filters 10, so this also sidesteps the filter cap for anyone in more than ten groups. Also folded into the same per-channel subscriptions: - Group activity addressed to me (reactions/zaps/replies) moved off the account-wide notifications subscription. That one also carries inbox filters with no `#h` at all, and buzz forces a subscription global on the first channel-less filter, so no reshaping there could ever have worked. - Reactions and deletions (kinds 5/7/9005) now ride the channel's own `#h` subscription, the shape Buzz's own client uses (`channelEventKinds`). Amethyst otherwise learned about them only through the shared `#e` EventFinder query, which carries no `#h` and is therefore global — so reaction chips appeared only on a re-query, never as they happened. Kept out of the timeline kind set so reactions cannot consume a history page's limit and walk the `until` cursor past undelivered messages. - A `limit = 1` preview filter with no time floor. The tail floors at now-7d to keep recent chat warm; on its own that stranded any channel quiet for longer on a "No messages yet" placeholder sorted to the bottom by createdAt 0. Every other roster-driven protocol on that screen already bounds by count for this reason (NIP-28 `limit = 1`, Concord `limit = 10`) — their row set comes from a list event, unlike NIP-17/NIP-04 whose rooms are discovered from the messages and so need the backward pagers. The Messages row now says "Loading" instead of "No messages yet" until the fleet settles, so an in-flight channel is not mistaken for an empty one. That required the shared WindowLoadTracker to describe the whole joined fleet rather than whichever channel rebuilt last. Co-Authored-By: Claude Opus 5 (1M context) --- ...NotificationsEoseFromInboxRelaysManager.kt | 70 ++------- .../BuzzDmJoinedChatTailFilterAssembler.kt | 46 ++++-- .../datasource/RelayGroupChatSubscriptions.kt | 48 +++++- .../datasource/RelayGroupFilterBuilders.kt | 139 +++++++++++++++--- ...RelayGroupJoinedChatTailFilterAssembler.kt | 91 +++++++----- .../RelayGroupOpenChatTailFilterAssembler.kt | 7 +- .../chats/rooms/ChatroomHeaderCompose.kt | 16 +- .../datasource/BuzzTimelineKindsTest.kt | 8 +- .../RelayGroupFilterBuildersTest.kt | 106 +++++++++++-- 9 files changed, 372 insertions(+), 159 deletions(-) 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/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 771a365887..3422b2b296 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 @@ -43,13 +43,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 @@ -147,6 +150,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 @@ -208,25 +238,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..42a3fe8650 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 @@ -458,9 +458,19 @@ private fun RelayGroupRoomCompose( // rather than "author: {json}". Plain chat messages fall through to the usual framing. buzzTimelinePreviewSummary(noteEvent) ?: "$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 } 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", From bb87d7755ad832fdbc1118cf99e50431343c4dfa Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 26 Jul 2026 12:44:36 -0400 Subject: [PATCH 02/15] feat(buzz): ask before showing channels somebody added you to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a Buzz relay, channel membership is server-side: another member can add you, the relay writes you into the kind-39002 roster, and you can read and post immediately. The relay then addresses you a kind-44100 naming who did it. Amethyst funnelled every 44100 into BuzzDmChannels — treating it as a DM — which silently subscribed you to that channel's messages, while the Messages list (which reads the self-published kind-10009) showed no row for it. A channel could therefore be joined, streaming, and invisible at the same time: the channel screen offered no Join button and accepted posts, the RelayGroups screen listed it from the relay's 39000 directory, messages arrived — and Messages had nothing. Nothing here is auto-accepted any more. 44100 carries `{"type","channel_id", "actor"}`, and the relay emits the SAME kind for a self-join with `actor == you`, so the actor is the only thing separating "I joined this" from "somebody put me here". Channels are classified by the `t` tag on their 39000 (stream/forum/dm/ workflow — read through a dedicated accessor because on buzz the type shares the tag name with real hashtags): only `t = dm` belongs in the DM list, everything else becomes a pending invite that subscribes to nothing. The prompt appears on both surfaces, driven by one state holder so they cannot disagree — Notifications, in the same header slot as the missing-inbox-relay prompt, and Messages > New Requests, beside the pending DMs it is the exact analogue of. Rendered as a list row rather than a modal: these arrive in bursts when somebody sets up a workspace, and a blocking dialog on cold start would be miserable. It is also the spam surface, so Ignore stays cheap. Three actions, and Ignore is deliberately not Leave: - Show -> writes the group into kind-10009 (Account.follow), after which the ordinary joined-group path owns it and it syncs to other devices. No kind-9021: the relay already has you in the roster, so this records only your decision to surface it. - Ignore -> local, reversible display choice. You stay in the roster and can still open and post. - Leave -> kind-9022 LeaveRequestEvent, the one that actually removes you. A kind-44101 removal now withdraws any pending prompt, so the relay taking the membership away cannot leave a card offering an action that would fail. The invites section is passed as the chatroom feed's header rather than stacked beside it: the collapsing top bar draws over that area, so a header outside the list renders underneath it. It shows in the empty state too, otherwise an account with no pending DMs would have no way to reach the prompt. Verified end to end on device: "straycat added you to personalized-knowledge- graphs" rendered on both surfaces, and Show republished kind-10009 with the channel appended. Co-Authored-By: Claude Opus 5 (1M context) --- .../amethyst/LocalPreferences.kt | 4 + .../amethyst/model/AccountSettings.kt | 27 ++++ .../amethyst/model/LocalCache.kt | 19 ++- .../loggedIn/AccountFeedContentStates.kt | 4 + .../ui/screen/loggedIn/AccountViewModel.kt | 29 ++++ .../screen/loggedIn/buzz/BuzzDmDiscovery.kt | 70 +++++++++- .../chats/rooms/feed/ChatroomListFeedView.kt | 28 +++- .../chats/rooms/feed/ChatroomListTabs.kt | 12 ++ .../notifications/ChannelInvitesSection.kt | 129 ++++++++++++++++++ .../notifications/ChannelInvitesState.kt | 64 +++++++++ .../notifications/NotificationScreen.kt | 7 +- amethyst/src/main/res/values/strings.xml | 6 + .../commons/model/buzz/BuzzChannelInvites.kt | 111 +++++++++++++++ .../commons/model/buzz/BuzzDmChannels.kt | 17 +++ .../MemberAddedNotificationEvent.kt | 10 ++ .../MemberRemovedNotificationEvent.kt | 10 ++ .../MembershipNotificationContent.kt | 81 +++++++++++ .../metadata/GroupMetadataEvent.kt | 24 ++++ 18 files changed, 641 insertions(+), 11 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/ChannelInvitesSection.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/ChannelInvitesState.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzChannelInvites.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/notifications/MembershipNotificationContent.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index ef75600b76..0193080cf8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -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/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index cf62e2454c..7b60f85867 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -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..7b345d1da4 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 @@ -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 @@ -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/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..2844127ec5 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 @@ -1683,6 +1684,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 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/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/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/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 650860393c..ebaa131c93 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. 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/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/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( From b2b2adf0762766fae41a47728f999a9b999122a8 Mon Sep 17 00:00:00 2001 From: davotoula Date: Sun, 26 Jul 2026 06:49:00 +0200 Subject: [PATCH 03/15] fix(audio): stop the inline audio player painting over the note --- .../components/AudioPlayerBoxOverflowTest.kt | 125 ++++++++++++++++++ .../ui/components/ZoomableContentView.kt | 28 +++- .../components/UnknownMediaAspectRatioTest.kt | 63 +++++++++ .../commons/richtext/RichTextParser.kt | 11 ++ .../richtext/RichTextParserAudioUrlTest.kt | 81 ++++++++++++ 5 files changed, 307 insertions(+), 1 deletion(-) create mode 100644 amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/components/AudioPlayerBoxOverflowTest.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/ui/components/UnknownMediaAspectRatioTest.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserAudioUrlTest.kt 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..9cea5fca62 --- /dev/null +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/components/AudioPlayerBoxOverflowTest.kt @@ -0,0 +1,125 @@ +/* + * 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 height = 0 + + fun modifier() = + Modifier.onGloballyPositioned { + top = it.positionInRoot().y + height = it.size.height + bottom = top + height + } + } + + private fun measure( + mimeType: String?, + 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(mimeType, 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(null, "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 audioWithAnExplicitMimeAlsoStaysInside() { + val (box, player) = measure("audio/mpeg", "https://example.com/download?id=7", square = true) + + assertTrue(player.top >= box.top && player.bottom <= box.bottom) + assertEquals(player.height, box.height) + } + + @Test + fun videoOfUnknownSizeKeeps16by9() { + val (box, _) = measure(null, "https://example.com/a.mp4", square = false) + + // 400.dp wide at 16:9. Rounding lands within a pixel either way. + val expected = box.height * 16f / 9f + assertTrue( + "expected a 16:9 box, got height=${box.height} for width=$expected", + kotlin.math.abs(expected - 400 * rule.density.density) <= 1f, + ) + } +} 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..2164723756 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,28 @@ 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? = + when { + mimeType != null -> if (mimeType.startsWith("audio/")) null else DEFAULT_VIDEO_ASPECT_RATIO + RichTextParser.isAudioUrl(url) -> null + else -> DEFAULT_VIDEO_ASPECT_RATIO + } + @Composable fun ZoomableContentView( content: BaseMediaContent, @@ -203,7 +226,10 @@ fun ZoomableContentView( } is MediaUrlVideo -> { - val ratio = content.dim?.aspectRatio() ?: MediaAspectRatioCache.get(content.url) ?: DEFAULT_VIDEO_ASPECT_RATIO + val ratio = + content.dim?.aspectRatio() + ?: MediaAspectRatioCache.get(content.url) + ?: unknownMediaAspectRatio(content.mimeType, content.url) val bridgedUrl = remember(content.url, useLocalBlossomBridge) { content.toCoilModel(useLocalBlossomBridge) 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/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..6e8ddcbf18 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 @@ -498,9 +498,15 @@ class RichTextParser { val videoExt = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3", "m3u8", "ogg", "wav", "flac", "aac", "opus", "m4a", "f4a") val pdfExt = listOf("pdf") + // The audio-only members of [videoExt] — 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 out: a playlist carries either. + val audioExt = listOf("mp3", "ogg", "wav", "flac", "aac", "opus", "m4a", "f4a") + 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 = @@ -542,6 +548,11 @@ class RichTextParser { return videoExtensions.any { removedParamsFromUrl.endsWith(it) } } + fun isAudioUrl(url: String): Boolean { + val removedParamsFromUrl = removeQueryParamsForExtensionComparison(url) + return audioExtensions.any { removedParamsFromUrl.endsWith(it) } + } + // 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/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..298692a4e4 --- /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 otherAudioContainersAreAudio() { + assertTrue(RichTextParser.isAudioUrl("https://example.com/a.wav")) + assertTrue(RichTextParser.isAudioUrl("https://example.com/a.flac")) + assertTrue(RichTextParser.isAudioUrl("https://example.com/a.aac")) + assertTrue(RichTextParser.isAudioUrl("https://example.com/a.opus")) + assertTrue(RichTextParser.isAudioUrl("https://example.com/a.m4a")) + assertTrue(RichTextParser.isAudioUrl("https://example.com/a.f4a")) + assertTrue(RichTextParser.isAudioUrl("https://example.com/a.ogg")) + } + + @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 audioExtensionsAreASubsetOfVideoExtensions() { + // Audio arrives as MediaUrlVideo precisely because videoExt lumps the two together. + RichTextParser.audioExt.forEach { + assertTrue(RichTextParser.videoExt.contains(it), "videoExt must still contain $it") + } + } +} From 482be60e4a8bc82bfa6e2a9a1bd9f562df0ca0ef Mon Sep 17 00:00:00 2001 From: davotoula Date: Sun, 26 Jul 2026 08:08:00 +0200 Subject: [PATCH 04/15] Code review: - fold audioExt into videoExt and share the classifier --- .../components/AudioPlayerBoxOverflowTest.kt | 24 +++--------- .../ui/components/ZoomableContentView.kt | 15 +++---- .../commons/richtext/RichTextParser.kt | 39 ++++++++++++------- .../richtext/RichTextParserAudioUrlTest.kt | 26 ++++++------- 4 files changed, 53 insertions(+), 51 deletions(-) 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 index 9cea5fca62..564715b2f9 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/components/AudioPlayerBoxOverflowTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/components/AudioPlayerBoxOverflowTest.kt @@ -55,18 +55,19 @@ class AudioPlayerBoxOverflowTest { 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( - mimeType: String?, url: String, square: Boolean, ): Pair { @@ -76,7 +77,7 @@ class AudioPlayerBoxOverflowTest { rule.setContent { Box(Modifier.size(width = 400.dp, height = 1200.dp)) { Box( - modifier = mediaSizingModifier(unknownMediaAspectRatio(mimeType, url), ContentScale.Fit).then(box.modifier()), + modifier = mediaSizingModifier(unknownMediaAspectRatio(null, url), ContentScale.Fit).then(box.modifier()), contentAlignment = Alignment.Center, ) { Box((if (square) Modifier.audioSquare() else Modifier).then(player.modifier())) @@ -90,7 +91,7 @@ class AudioPlayerBoxOverflowTest { @Test fun squareAudioPlayerStaysInsideItsMediaBox() { - val (box, player) = measure(null, "https://haven.sdbitcoiners.com/f28a5a2e.mp3", square = true) + 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", @@ -103,23 +104,10 @@ class AudioPlayerBoxOverflowTest { assertEquals("the box should wrap the square", player.height, box.height) } - @Test - fun audioWithAnExplicitMimeAlsoStaysInside() { - val (box, player) = measure("audio/mpeg", "https://example.com/download?id=7", square = true) - - assertTrue(player.top >= box.top && player.bottom <= box.bottom) - assertEquals(player.height, box.height) - } - @Test fun videoOfUnknownSizeKeeps16by9() { - val (box, _) = measure(null, "https://example.com/a.mp4", square = false) + val (box, _) = measure("https://example.com/a.mp4", square = false) - // 400.dp wide at 16:9. Rounding lands within a pixel either way. - val expected = box.height * 16f / 9f - assertTrue( - "expected a 16:9 box, got height=${box.height} for width=$expected", - kotlin.math.abs(expected - 400 * rule.density.density) <= 1f, - ) + assertEquals(16f / 9f, box.width.toFloat() / box.height, 0.01f) } } 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 2164723756..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 @@ -160,12 +160,7 @@ private const val DEFAULT_VIDEO_ASPECT_RATIO = 16f / 9f internal fun unknownMediaAspectRatio( mimeType: String?, url: String, -): Float? = - when { - mimeType != null -> if (mimeType.startsWith("audio/")) null else DEFAULT_VIDEO_ASPECT_RATIO - RichTextParser.isAudioUrl(url) -> null - else -> DEFAULT_VIDEO_ASPECT_RATIO - } +): Float? = if (RichTextParser.isAudioContent(mimeType, url)) null else DEFAULT_VIDEO_ASPECT_RATIO @Composable fun ZoomableContentView( @@ -226,10 +221,16 @@ fun ZoomableContentView( } is MediaUrlVideo -> { + // 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) - ?: unknownMediaAspectRatio(content.mimeType, content.url) + ?: fallbackRatio val bridgedUrl = remember(content.url, useLocalBlossomBridge) { content.toCoilModel(useLocalBlossomBridge) 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 6e8ddcbf18..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,13 +495,20 @@ 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") - val pdfExt = listOf("pdf") - // The audio-only members of [videoExt] — 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 out: a playlist carries either. + // 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() } @@ -518,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 } @@ -553,6 +559,13 @@ class RichTextParser { 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/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserAudioUrlTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserAudioUrlTest.kt index 298692a4e4..f8537790a1 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserAudioUrlTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserAudioUrlTest.kt @@ -31,14 +31,10 @@ class RichTextParserAudioUrlTest { } @Test - fun otherAudioContainersAreAudio() { - assertTrue(RichTextParser.isAudioUrl("https://example.com/a.wav")) - assertTrue(RichTextParser.isAudioUrl("https://example.com/a.flac")) - assertTrue(RichTextParser.isAudioUrl("https://example.com/a.aac")) - assertTrue(RichTextParser.isAudioUrl("https://example.com/a.opus")) - assertTrue(RichTextParser.isAudioUrl("https://example.com/a.m4a")) - assertTrue(RichTextParser.isAudioUrl("https://example.com/a.f4a")) - assertTrue(RichTextParser.isAudioUrl("https://example.com/a.ogg")) + fun everyAudioContainerIsAudio() { + RichTextParser.audioExt.forEach { + assertTrue(RichTextParser.isAudioUrl("https://example.com/a.$it"), it) + } } @Test @@ -72,10 +68,14 @@ class RichTextParserAudioUrlTest { } @Test - fun audioExtensionsAreASubsetOfVideoExtensions() { - // Audio arrives as MediaUrlVideo precisely because videoExt lumps the two together. - RichTextParser.audioExt.forEach { - assertTrue(RichTextParser.videoExt.contains(it), "videoExt must still contain $it") - } + 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")) } } From ec0c4a6c2a95d32d23f7325cff36bfd3671347cb Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 26 Jul 2026 15:13:05 -0400 Subject: [PATCH 05/15] fix(buzz): thread kind-9 replies into the minichat instead of the channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A reply written by any current Buzz client is a kind-9 carrying a NIP-10 `reply`-marked `e` tag. Amethyst rendered it as a flat row in the main channel with the parent quoted above it, and it never appeared in the thread on its parent — the exact inverse of where Buzz puts it. Observed on the wire: parent kind 9 tags: [h, ] reply kind 9 tags: [h, ], [e, , "", "reply"] `isMinichatReply` is the single definition three consumers share — the channel timeline filter drops these, the reply-count chip counts them, and the minichat feed shows them — but it was type-gated to CommentEvent (1111) and StreamMessageV2Event (40002), so a kind-9 ChatEvent fell through to `false`. Every downstream behaviour followed from that one gap: the reply stayed in the timeline, the parent showed no "N replies" chip, and the thread was empty. Accepting a marked `e` on kind 9 is NIP-C7 compliant. C7 defines exactly one reply mechanism for kind 9 — `["q", , , ]` — and never mentions `e` at all, so a marked `e` carries no C7 meaning and is free to denote a thread reply. Matching on the MARKER (never the bare tag) is what keeps WhiteNoise/Marmot working: they thread kind-9 chat with a plain, unmarked `e`, which is an in-chat reply and must keep rendering as a quote bubble. Tests pin all four cases: marked direct, marked nested (root+reply), unmarked, and `q`. Also stop writing kind-40002 for Buzz minichat replies. Nothing in Buzz writes 40002 any more: every send path in their mobile, desktop and CLI clients emits kind 9, the ~50 remaining references are all reads (filter kind lists, feed query sets, archive constants), and their NOSTR.md grades kind:9 as supported against 40002's "Buzz-only — no standard NIP-29 client renders these". It is a read-compat tail from the 10002 -> 40001 -> 40002 migration, and Amethyst was the last active writer — so our replies threaded nowhere but our own client. We now emit kind 9 with tags byte-identical to Buzz's `_buildReplyTags` (direct -> one `reply` marker; nested -> `root` + `reply`), which is what `buzzThread` already produced. Reading 40002 stays supported for events already in the wild, including the ones we wrote. Deliberately NOT changed: `computeReplyTo`'s ChatEvent branch still links every `e` tag to the parent. That link is what populates `note.replies`, which the thread and the chip read — discriminating there would unlink Buzz replies from their parents. The marker distinction belongs in rendering, not in linkage. Verified on device against a real thread: the reply now sits in the thread on "howd you get bumble working?" with a "3 replies" chip on the parent, and is gone from the channel timeline. Co-Authored-By: Claude Opus 5 (1M context) --- .../vitorpamplona/amethyst/model/Account.kt | 21 +++- .../ui/screen/loggedIn/chats/MinichatReply.kt | 27 ++++- .../loggedIn/chats/MinichatReplyTest.kt | 99 +++++++++++++++++++ 3 files changed, 138 insertions(+), 9 deletions(-) create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/MinichatReplyTest.kt 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 c0a35d5f47..606a095faf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -165,7 +165,6 @@ import com.vitorpamplona.quartz.buzz.dm.DmOpenEvent 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 @@ -2438,12 +2437,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/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/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")))) + } +} From 4f6e16a74e6e4d96081280fef166a9c6d2fe223c Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 26 Jul 2026 16:00:49 -0400 Subject: [PATCH 06/15] fix(nip29): line up preview, timeline and unread on one predicate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A minichat thread reply was showing as a group's "last message" on Messages, and lighting its unread dot, even though the channel timeline correctly hides it. Three surfaces disagreed about what counts as a message: channel timeline ChannelFeedFilter !isMinichatReply(..) && isAcceptable Messages preview newestChatNote isGroupChatContent() && isAcceptable unread dot hasChatNewerThan isGroupChatContent() && isAcceptable So a reply that the channel deliberately routes to its thread still became the row summary, and still lit the dot — you would open the group, see nothing new, and watch the dot clear. The Concord side already solved exactly this by sharing one predicate (`isConcordTimelineMessage`) across feed, preview and badge; this gives NIP-29 the same treatment: isRelayGroupTimelineMessage = isGroupChatContent && !isMinichatReply && isAcceptable Now used by: - `newestTimelineNote` (replaces the local `newestChatNote`) for the row preview, in both INLINE and GROUPED view modes - both additive paths in ChatroomListKnownFeedFilter, so an arriving reply cannot bump a row either - `hasChatNewerThan`, which backs the per-channel dot (`relayGroupChannelHasUnread Flow`, also used by the workspace channel list), the collapsed per-relay dot (`relayGroupServerHasUnreadFlow` composes it), and transitively the Messages row dot, which reads the createdAt of the note the preview picked The bottom-bar Messages badge follows automatically: it derives from the feed rows themselves. (It only counts private DMs today — `unreadPrivateChatRoute` returns null for anything that isn't ChatroomKeyable — which is a separate, pre-existing scope decision, untouched here.) Verified on device by creating the case rather than waiting for it: posted a reply into a thread so it became the newest event in the channel. The thread shows it (chip 4 -> 5 replies), the channel timeline does not, and the Messages row still previews the newest timeline event. Before this change that reply would have been the row summary. The earlier screenshot only looked right because a system message happened to be newer. Co-Authored-By: Claude Opus 5 (1M context) --- .../relayGroup/RelayGroupUnread.kt | 37 ++++++++++++++++++- .../rooms/dal/ChatroomListKnownFeedFilter.kt | 20 ++++------ 2 files changed, 42 insertions(+), 15 deletions(-) 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/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 From 3813e248c274b06334dbe26a5014a22761df370e Mon Sep 17 00:00:00 2001 From: davotoula Date: Sun, 26 Jul 2026 22:21:24 +0200 Subject: [PATCH 07/15] i18n: translate Buzz, Blossom import and BOLT12 strings into cs, de, pt-BR, sv MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fills the on-disk translation gap for the recently merged Buzz workspaces, Blossom file import and BOLT12 offers features, plus the channel-invite and payment-notification strings. Inserted per-locale (128 cs, 124 de-rDE, 126 sv-rSE, 128 pt-rBR) driven off each file's own diff rather than a shared union block, since Crowdin strips source-identical keys asymmetrically across locales. All 7 new are covered in every locale; Czech gets the full CLDR one/few/many/other set. Source-identical entries (Canvas, Workspace, OK, LIVE, Media, Social, Reposts, and the bare %1$d+ count formats) are deliberately left out — Crowdin strips those on export and Android falls back to values/strings.xml at runtime. Verified: no duplicate keys, well-formed XML, and format-placeholder parity with the English source across all four files. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01T45dBdJ8GRBy8zw9yDQRPW --- amethyst/src/main/res/values-cs/strings.xml | 163 ++++++++++++++++++ .../src/main/res/values-de-rDE/strings.xml | 145 ++++++++++++++++ .../src/main/res/values-pt-rBR/strings.xml | 149 ++++++++++++++++ .../src/main/res/values-sv-rSE/strings.xml | 147 ++++++++++++++++ 4 files changed, 604 insertions(+) diff --git a/amethyst/src/main/res/values-cs/strings.xml b/amethyst/src/main/res/values-cs/strings.xml index 7ff42d6b78..534254ffd9 100644 --- a/amethyst/src/main/res/values-cs/strings.xml +++ b/amethyst/src/main/res/values-cs/strings.xml @@ -4431,4 +4431,167 @@ Anonymní — jednorázová identita pro každou oblast, nikdy váš npub. Kompatibilní s Bitchatem; zasáhne blízké uživatele na stejných relayích. Veřejné a dočasné — žádná historie, může to číst kdokoli v buňce. + Konzole agenta + Upozorní vás, když vaše připojená peněženka obdrží platbu, která není Nostr zapem + Přijato %1$s sats + Přijaté platby + Nové platby + Podrobnosti souboru + Nebo vložte adresu serveru + Synchronizace souborů už probíhá. Zkuste import znovu, až skončí. + Vypnout vše + Zapnout vše + Prohledejte ostatní Blossom servery a najděte soubory, které jste nahráli jinde, a zkopírujte je na své vlastní servery. + Spravovat mé servery + Importovat soubory… + Nejprve přidejte vlastní Blossom servery, aby bylo kam importované soubory zkopírovat. + Na vybraných serverech nebyly nalezeny žádné nové soubory. + Prohledat servery + Prohledávání… + Tento server je nedostupný + Servery k prohledání + Importovat soubory + Další akce + Není na všech serverech + Na všech serverech + Uloženo na + Nabídka BOLT12 (lno1…) + 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. + Zaplatit připojenou peněženkou + Částka (sats) + Platba BOLT12 selhala: %1$s + Platba BOLT12 odeslána + Chyba zapu BOLT12 + Zaplaceno přes BOLT12, ale důkaz o platbě se nepodařilo ověřit, takže nebyla zveřejněna žádná zap účtenka. + Zaplaceno přes BOLT12, ale peněženka nevrátila žádný důkaz, takže nebyla zveřejněna žádná zap účtenka. + Pracuji… + Plátno (Markdown) + Upravit plátno + V tomto pracovním prostoru zatím nebylo sdíleno žádné plátno. + Uložit plátno + Plátno + Přidat lidi do tohoto pracovního prostoru + Náklady · Persony · Živý pozorovatel + Konzole agenta + (rozdíl zkrácen) + Přidat někoho (npub nebo hex) + Přidat člena + Neplatný npub nebo hex klíč + Přidat někoho do této konverzace + Soukromé konverzace ve vašich pracovních prostorech + Začněte soukromou konverzaci s kýmkoli v pracovním prostoru Buzz. + Zatím žádné přímé zprávy + Skrýt konverzaci + Jen vy + Více + Zprávy Buzz + Nová zpráva + Otevírání… + Komu + Odebrat + Zatím žádné konverzace + Zahájit konverzaci + Přímé zprávy + Pracovní prostor + Upravit + Úprava zprávy + O čem chcete diskutovat? + Nové téma fóra + Zatím žádné odpovědi. Buďte první, kdo odpoví. + Odeslat téma + Vlákno fóra + Přidat + Přidat vše + Přidáno + 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. + Nenalezeny žádné kanály + Hledání vašich kanálů… + Importovat kanály pracovního prostoru + Vaše kanály v tomto pracovním prostoru + Dokončete přijetí podmínek v prohlížeči a pak se vraťte — váš pracovní prostor tu na vás bude čekat. + 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 + Kopírovat + Vytvořit odkaz s pozvánkou + Pozvánku se nepodařilo vytvořit + Platnost této pozvánky vypršela. Požádejte o novou. + Připojit se k tomuto pracovnímu prostoru + Toto nevypadá jako platný odkaz s pozvánkou Buzz. + Odkaz s pozvánkou + Otevřít mé pracovní prostory + Sdílet + Pozvánka do pracovního prostoru + Pracovní prostor + (upraveno) + Připnout kanál + Několik lidí píše… + %1$s píše… + %1$s a %2$s píší… + Odepnout kanál + Procházet skupiny + 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. + Zatím žádné pracovní prostory + Kde lidé a agenti tvoří společně + Opustit pracovní prostor + Vaše pracovní prostory + Pracovní prostory Buzz + Zobrazit + %1$s vás přidal do tohoto kanálu na %2$s. Zobrazit ho ve Zprávách? + Ignorovat + Opustit + Přidáno do %1$s + Někdo + Vy: %1$s + Odeslat obrázek + Úprava zprávy + Smazat nabídku BOLT12 + Upravit + Neplatná nabídka BOLT12 + (upraveno) + Nejsou nastaveny žádné nabídky BOLT12. Přidejte jednu níže ↓ + ŽIVĚ + + %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ů + + + Zobrazit další %1$d konverzaci + Zobrazit všechny %1$d konverzace + Zobrazit všech %1$d konverzace + Zobrazit všech %1$d konverzací + + + %1$d kanál + %1$d kanály + %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 skupina + %1$d skupiny + %1$d skupiny + %1$d skupin + diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index e331704c28..60e8f5674b 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -4279,4 +4279,149 @@ Anonym — eine Wegwerf-Identität pro Gebiet, niemals dein npub. Bitchat-kompatibel; erreicht Nutzer in der Nähe auf denselben Relays. Öffentlich und flüchtig — kein Verlauf, jeder in der Zelle kann es lesen. + Agent-Konsole + Benachrichtigt dich, wenn deine verbundene Wallet eine Zahlung erhält, die kein Nostr-Zap ist + %1$s sats erhalten + Erhaltene Zahlungen + Neue Zahlungen + Dateidetails + Oder füge eine Serveradresse ein + Eine Dateisynchronisierung läuft bereits. Versuche den Import erneut, sobald sie abgeschlossen ist. + Alle deaktivieren + Alle aktivieren + Durchsuche andere Blossom-Server nach Dateien, die du anderswo hochgeladen hast, und kopiere sie auf deine eigenen Server. + Meine Server verwalten + Dateien importieren… + Füge zuerst eigene Blossom-Server hinzu, damit die importierten Dateien irgendwohin kopiert werden können. + Auf den ausgewählten Servern wurden keine neuen Dateien gefunden. + Server prüfen + Wird geprüft… + Dieser Server ist nicht erreichbar + Zu prüfende Server + Dateien importieren + Weitere Aktionen + Nicht auf allen Servern + Auf allen Servern + Gespeichert auf + BOLT12-Angebot (lno1…) + 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. + Mit verbundener Wallet bezahlen + Betrag (sats) + BOLT12-Zahlung fehlgeschlagen: %1$s + BOLT12-Zahlung gesendet + BOLT12-Zap-Fehler + Über BOLT12 bezahlt, aber der Zahlungsnachweis konnte nicht validiert werden, daher wurde keine Zap-Quittung veröffentlicht. + Über BOLT12 bezahlt, aber die Wallet hat keinen Nachweis zurückgegeben, daher wurde keine Zap-Quittung veröffentlicht. + Arbeitet… + Canvas bearbeiten + In diesem Workspace wurde noch kein Canvas geteilt. + Canvas speichern + Personen zu diesem Workspace hinzufügen + Kosten · Personas · Live-Beobachter + Agent-Konsole + (Diff gekürzt) + Jemanden hinzufügen (npub oder hex) + Mitglied hinzufügen + Kein gültiger npub- oder Hex-Schlüssel + Jemanden zu dieser DM hinzufügen + Private Unterhaltungen in deinen Workspaces + Beginne eine private Unterhaltung mit jemandem in einem Buzz-Workspace. + Noch keine Direktnachrichten + Unterhaltung ausblenden + Nur du + Mehr + Buzz-DMs + Neue Nachricht + Wird geöffnet… + An + Entfernen + Noch keine Unterhaltungen + Unterhaltung beginnen + Direktnachrichten + Bearbeiten + Nachricht bearbeiten + Worüber möchtest du diskutieren? + Neues Forumsthema + Noch keine Antworten. Sei die erste Person, die antwortet. + Thema veröffentlichen + Forum-Thread + Hinzufügen + Alle hinzufügen + Hinzugefügt + 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. + Keine Kanäle gefunden + Deine Kanäle werden gesucht… + Workspace-Kanäle importieren + Deine Kanäle in diesem Workspace + Schließe die Zustimmung zu den Bedingungen im Browser ab und komm dann zurück — dein Workspace wartet auf dich. + 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 + Kopieren + Einladungslink erstellen + Einladung konnte nicht erstellt werden + Diese Einladung ist abgelaufen. Bitte um eine neue. + Diesem Workspace beitreten + Das sieht nicht nach einem gültigen Buzz-Einladungslink aus. + Einladungslink + Meine Workspaces öffnen + Rolle + Teilen + Workspace-Einladung + (bearbeitet) + Kanal anheften + Mehrere Personen tippen… + %1$s tippt… + %1$s und %2$s tippen… + Kanal nicht mehr anheften + Gruppen durchsuchen + Buzz-Workspaces sind Relays, in denen Menschen und KI-Agenten gemeinsam bauen. Tritt einem bei, um ihn hier zu sehen. + Noch keine Workspaces + Wo Menschen und Agenten gemeinsam bauen + Workspace verlassen + Deine Workspaces + Buzz-Workspaces + Anzeigen + %1$s hat dich auf %2$s zu diesem Kanal hinzugefügt. In Nachrichten anzeigen? + Ignorieren + Verlassen + Zu %1$s hinzugefügt + Jemand + Du: %1$s + Bild senden + Nachricht bearbeiten + BOLT12-Angebot löschen + Bearbeiten + Kein gültiges BOLT12-Angebot + (bearbeitet) + Keine BOLT12-Angebote festgelegt. Füge unten eines hinzu ↓ + + %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 + + + %1$d weitere Unterhaltung anzeigen + Alle %1$d Unterhaltungen anzeigen + + + %1$d Kanal + %1$d Kanäle + + + %1$d Workspace + %1$d Workspaces + + + %1$d Gruppe + %1$d Gruppen + diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 3e68c3236f..f9c600f564 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -4277,4 +4277,153 @@ Anônimo — uma identidade descartável por área, nunca seu npub. Compatível com Bitchat; alcança usuários próximos nos mesmos relays. Público e efêmero — sem histórico, qualquer um na célula pode ler. + Console do agente + Notifica você quando sua carteira conectada recebe um pagamento que não é um zap do Nostr + Recebidos %1$s sats + Pagamentos recebidos + Novos pagamentos + Repostagens + Detalhes do arquivo + Ou cole um endereço de servidor + Uma sincronização de arquivos já está em andamento. Tente importar novamente quando ela terminar. + Desativar todos + Ativar todos + Procure em outros servidores Blossom por arquivos que você enviou em outro lugar e copie-os para os seus próprios servidores. + Gerenciar meus servidores + Importar arquivos… + Adicione primeiro seus próprios servidores Blossom, para que haja onde copiar os arquivos importados. + Nenhum arquivo novo foi encontrado nos servidores selecionados. + Verificar servidores + Verificando… + Não foi possível acessar este servidor + Servidores a verificar + Importar arquivos + Mais ações + Não está em todos os servidores + Em todos os servidores + Armazenado em + Oferta BOLT12 (lno1…) + 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ê. + Pagar com a carteira conectada + Valor (sats) + Falha no pagamento BOLT12: %1$s + Pagamento BOLT12 enviado + Erro no zap BOLT12 + Pago via BOLT12, mas o comprovante de pagamento não foi validado, então nenhum recibo de zap foi publicado. + Pago via BOLT12, mas a carteira não retornou nenhum comprovante, então nenhum recibo de zap foi publicado. + Trabalhando… + Editar canvas + Nenhum canvas foi compartilhado neste espaço de trabalho ainda. + Salvar canvas + Adicionar pessoas a este espaço de trabalho + Custos · Personas · Observador ao vivo + Console do agente + (diff truncado) + Adicionar alguém (npub ou hex) + Adicionar membro + Não é uma chave npub ou hex válida + Adicionar alguém a esta DM + Conversas privadas nos seus espaços de trabalho + Inicie uma conversa privada com qualquer pessoa em um espaço de trabalho Buzz. + Nenhuma mensagem direta ainda + Ocultar conversa + Só você + Mais + DMs do Buzz + Nova mensagem + Abrindo… + Para + Remover + Nenhuma conversa ainda + Iniciar conversa + Mensagens diretas + Espaço de trabalho + Editar + Editando mensagem + O que você quer discutir? + Novo tópico do fórum + Nenhuma resposta ainda. Seja a primeira pessoa a responder. + Publicar tópico + Discussão do fórum + Adicionar + Adicionar todos + Adicionado + 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. + Nenhum canal encontrado + Procurando seus canais… + Importar canais do espaço de trabalho + Seus canais neste espaço de trabalho + Termine de aceitar os termos no navegador e depois volte — seu espaço de trabalho estará esperando. + 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 + Copiar + Criar link de convite + Não foi possível criar o convite + Este convite expirou. Peça um novo. + Entrar neste espaço de trabalho + Isso não parece ser um link de convite válido do Buzz. + Link de convite + Abrir meus espaços de trabalho + Função + Compartilhar + Convite para espaço de trabalho + Espaço de trabalho + (editado) + Fixar canal + Várias pessoas estão digitando… + %1$s está digitando… + %1$s e %2$s estão digitando… + Desafixar canal + Explorar grupos + Espaços de trabalho Buzz são relays onde humanos e agentes de IA constroem juntos. Entre em um para vê-lo aqui. + Nenhum espaço de trabalho ainda + Onde humanos e agentes constroem juntos + Sair do espaço de trabalho + Seus espaços de trabalho + Espaços de trabalho Buzz + Mostrar + %1$s adicionou você a este canal em %2$s. Mostrar em Mensagens? + Ignorar + Sair + Adicionado a %1$s + Alguém + Você: %1$s + Enviar imagem + Editando mensagem + Excluir oferta BOLT12 + Editar + Não é uma oferta BOLT12 válida + (editado) + Nenhuma oferta BOLT12 definida. Adicione uma abaixo ↓ + AO VIVO + + %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 + + + Ver mais %1$d conversa + Ver todas as %1$d conversas + + + %1$d canal + %1$d canais + + + %1$d espaço de trabalho + %1$d espaços de trabalho + + + %1$d grupo + %1$d grupos + diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 09f209ec53..76da1d936a 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -4278,4 +4278,151 @@ Anonym — en engångsidentitet per område, aldrig din npub. Bitchat-kompatibel; når närliggande användare på samma reläer. Offentlig och flyktig — ingen historik, alla i cellen kan läsa den. + Agentkonsol + Meddelar dig när din anslutna plånbok tar emot en betalning som inte är en Nostr-zap + Tog emot %1$s sats + Mottagna betalningar + Nya betalningar + Filinformation + Eller klistra in en serveradress + En filsynkronisering pågår redan. Försök importera igen när den är klar. + Inaktivera alla + Aktivera alla + Sök på andra Blossom-servrar efter filer du laddat upp någon annanstans och kopiera dem till dina egna servrar. + Hantera mina servrar + Importera filer… + Lägg till dina egna Blossom-servrar först, så att de importerade filerna har någonstans att kopieras till. + Inga nya filer hittades på de valda servrarna. + Sök på servrar + Söker… + Kunde inte nå den här servern + Servrar att söka på + Importera filer + Fler åtgärder + Inte på alla servrar + På alla servrar + Lagrad på + BOLT12-erbjudande (lno1…) + 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. + Betala med ansluten plånbok + Belopp (sats) + BOLT12-betalningen misslyckades: %1$s + BOLT12-betalning skickad + BOLT12-zapfel + Betalt via BOLT12, men betalningsbeviset kunde inte valideras, så inget zapkvitto publicerades. + Betalt via BOLT12, men plånboken returnerade inget bevis, så inget zapkvitto publicerades. + Arbetar… + Redigera canvas + Ingen canvas har delats i den här arbetsytan än. + Spara canvas + Lägg till personer i den här arbetsytan + Kostnader · Personas · Liveobservatör + Agentkonsol + (diff avkortad) + Lägg till någon (npub eller hex) + Lägg till medlem + Inte en giltig npub- eller hexnyckel + Lägg till någon i det här DM:et + Privata konversationer i dina arbetsytor + Starta en privat konversation med vem som helst i en Buzz-arbetsyta. + Inga direktmeddelanden än + Dölj konversation + Bara du + Mer + Buzz-DM + Nytt meddelande + Öppnar… + Till + Ta bort + Inga konversationer än + Starta konversation + Direktmeddelanden + Arbetsyta + Redigera + Redigerar meddelande + Vad vill du diskutera? + Nytt forumämne + Inga svar än. Bli först med att svara. + Publicera ämne + Forumtråd + Lägg till + Lägg till alla + Tillagd + 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. + Inga kanaler hittades + Hittar dina kanaler… + Importera arbetsytans kanaler + Dina kanaler i den här arbetsytan + Slutför godkännandet av villkoren i webbläsaren och kom sedan tillbaka — din arbetsyta väntar. + 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 + Kopiera + Skapa inbjudningslänk + Kunde inte skapa inbjudan + Den här inbjudan har gått ut. Be om en ny. + Gå med i den här arbetsytan + Det här ser inte ut som en giltig Buzz-inbjudningslänk. + Inbjudningslänk + Öppna mina arbetsytor + Roll + Dela + Arbetsyteinbjudan + Arbetsyta + (redigerat) + Fäst kanal + Flera personer skriver… + %1$s skriver… + %1$s och %2$s skriver… + Lossa kanal + Bläddra bland grupper + 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. + Inga arbetsytor än + Där människor och agenter bygger tillsammans + Lämna arbetsytan + Dina arbetsytor + Buzz-arbetsytor + Visa + %1$s lade till dig i den här kanalen på %2$s. Visa den i Meddelanden? + Ignorera + Lämna + Tillagd i %1$s + Någon + Du: %1$s + Skicka bild + Redigerar meddelande + Ta bort BOLT12-erbjudande + Redigera + Inte ett giltigt BOLT12-erbjudande + (redigerat) + Inga BOLT12-erbjudanden angivna. Lägg till ett nedan ↓ + + %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 + + + Visa %1$d konversation till + Visa alla %1$d konversationer + + + %1$d kanal + %1$d kanaler + + + %1$d arbetsyta + %1$d arbetsytor + + + %1$d grupp + %1$d grupper + From 07e3f21509685aab356ca5f6d585a1ed4673fbb8 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 26 Jul 2026 16:22:22 -0400 Subject: [PATCH 08/15] fix(chats): light the Messages badge for every row type, notify Buzz thread replies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two halves of the same gap: a row could show its blue dot while nothing above it agreed. 1. Bottom-bar envelope counted only DMs ------------------------------------------------------------------------------ `messagesHasNewItems` mapped each Messages row through `unreadPrivateChatRoute`, 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 were silently skipped — public chats, ephemeral rooms, geohash cells, Marmot groups, NIP-29/Buzz channels, Concord channels, and both collapsed "grouped" rows. Each of those rows already computed its own dot from its own last-read route; the badge just never asked. `rowHasUnreadFlow` now answers, per row, the same question the row composable answers for itself, keyed off the note's gatherer (a Buzz channel and a Concord channel can both carry a kind-9, so event kind alone can't tell them apart). It returns a Flow rather than a (route, createdAt) pair because the two collapsed rows fan in over every child channel — approximating those by their newest child would miss an older channel that is still unread. 2. Buzz thread replies notified nothing ------------------------------------------------------------------------------ Buzz's clients thread with `["e", , "", "reply"]` and only ever `p`-tag @mentions, so a reply to my message names me nowhere. `isNotifiablePublicChatRep ly` — the rule that lets a reply notify without a `p` tag — bails unless the event is a ChannelMessageEvent (kind 42), so a kind-9 thread reply qualified under nothing. Combined with thread replies now being kept out of the channel timeline and its unread dot, a reply to my message in a Buzz channel had become invisible on every surface. `isBuzzThreadReplyToMyEvent` mirrors the fix already used for Buzz reactions (`isReactionToMyEvent`): when a chat event carries no `p` tag, resolve the author of its `root`/`reply` marked `e` targets instead of trusting a tag. Deliberately only the MARKED targets — a bare `e` is WhiteNoise/Marmot's in-chat reply, not a thread. It is OR'd into the same three gates the reaction case uses, so a reply from a channel member I don't follow still notifies. Also admits kind-40002 into NOTIFICATION_KINDS: nothing writes it any more, but legacy Buzz thread replies exist and were being dropped at the kind gate before any relevance check ran. Co-Authored-By: Claude Opus 5 (1M context) --- .../ui/screen/loggedIn/AccountViewModel.kt | 36 ++----- .../loggedIn/chats/rooms/ChatroomRowUnread.kt | 100 ++++++++++++++++++ .../dal/NotificationFeedFilter.kt | 53 ++++++++-- 3 files changed, 158 insertions(+), 31 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomRowUnread.kt 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 2844127ec5..c3e4633ab2 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 @@ -76,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 @@ -105,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 @@ -440,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 @@ -450,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 -> @@ -466,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 = @@ -2203,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/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/notifications/dal/NotificationFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt index f4ac0d4f2c..4685312f59 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 @@ -33,6 +33,8 @@ import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder 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.workspace.buzzParticipants import com.vitorpamplona.quartz.buzz.workspace.isBuzzDm import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent @@ -148,10 +150,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, @@ -414,6 +422,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, @@ -534,6 +572,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). @@ -552,14 +593,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() From 67223d612f11a4bb584a80f9bb02f31c810c8988 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 20:32:16 +0000 Subject: [PATCH 09/15] refactor: rename BOLT12 zaps placeholder NIP to the assigned NIP-B1 The BOLT12 zaps feature was built against a placeholder NIP identifier ("nipXX" / "NIP-XX", and "NIP-2421" in one plan). The number NIP-B1 has now been assigned, so update the naming across every module: - rename the Quartz package `nipXXBolt12Zaps` -> `nipB1Bolt12Zaps` (commonMain + commonTest) and every import referencing it. - KDocs/comments: `NIP-XX` -> `NIP-B1` in quartz, commons, amethyst, cli. - wire binding prefix: `nostr:nipXX:` -> `nostr:nipB1:` (Bolt12ZapValidator.NIP_URI_PREFIX, NIP-47 pay `payer_note`, tests). - KindNames: Bolt12 Zap / Bolt12 Offers NIP number "XX" -> "B1". - plan doc references `NIP-2421` -> `NIP-B1`. Leaves the unrelated `nipXXPodcasting20` package and the audio-rooms draft (also placeholder "NIP-XX") untouched. quartz main + test compile and spotless is clean. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_012P3krSe92wicpswBr2CP9s --- amethyst/plans/2026-07-24-nwc-bolt12-pay.md | 16 +++++++-------- .../amethyst/LocalPreferences.kt | 2 +- .../vitorpamplona/amethyst/model/Account.kt | 6 +++--- .../amethyst/model/AccountSettings.kt | 2 +- .../amethyst/model/LocalCache.kt | 14 ++++++------- .../bolt12Offers/Bolt12OfferListState.kt | 4 ++-- .../amethyst/service/ZapPaymentHandler.kt | 4 ++-- .../notifications/NotificationDispatcher.kt | 2 +- .../FilterAccountInfoAndListsFromKey.kt | 2 +- .../FilterNotificationsToPubkey.kt | 2 +- .../FilterRepliesAndReactionsToAddresses.kt | 2 +- .../FilterRepliesAndReactionsToNotes.kt | 2 +- .../user/watchers/FilterUserMetadataForKey.kt | 2 +- .../bolt12Offers/Bolt12OffersViewModel.kt | 4 ++-- .../amethyst/ui/note/Bolt12ZapGallery.kt | 2 +- .../amethyst/ui/note/NoteCompose.kt | 2 +- .../amethyst/ui/note/ZapCustomDialog.kt | 2 +- .../amethyst/ui/note/types/Bolt12ZapEvent.kt | 4 ++-- .../ui/screen/loggedIn/AccountViewModel.kt | 2 +- .../FilterGoalForLiveActivity.kt | 2 +- .../FilterMessagesToLiveStream.kt | 2 +- .../datasource/NestRoomFilterAssembler.kt | 2 +- .../room/lifecycle/NestRoomEventCollectors.kt | 2 +- .../notifications/NotificationSummaryState.kt | 2 +- .../dal/NotificationFeedFilter.kt | 2 +- .../FilterUserProfileZapReceived.kt | 2 +- .../profile/header/Bolt12PayButton.kt | 2 +- .../zaps/dal/UserProfileZapsViewModel.kt | 2 +- .../screen/loggedIn/relays/KindDisplayName.kt | 2 +- .../loggedIn/threadview/ThreadFeedView.kt | 2 +- .../appfunctions/AmethystAppFunctions.kt | 2 +- cli/README.md | 2 +- cli/ROADMAP.md | 2 +- .../com/vitorpamplona/amethyst/cli/Main.kt | 2 +- .../amethyst/cli/commands/Bolt12Commands.kt | 12 +++++------ .../cli/commands/Bolt12SendCommands.kt | 6 +++--- .../commons/actions/Bolt12ZapActions.kt | 20 +++++++++---------- .../amethyst/commons/model/Bolt12ZapEntry.kt | 4 ++-- .../amethyst/commons/model/Note.kt | 10 +++++----- .../amethyst/commons/model/ThreadAssembler.kt | 2 +- .../amethyst/commons/model/User.kt | 4 ++-- .../notifications/NotificationKinds.kt | 4 ++-- .../LiveStreamTopZappersViewModel.kt | 2 +- .../commons/viewmodels/RoomZapsState.kt | 2 +- .../commons/actions/Bolt12ZapActionsTest.kt | 10 +++++----- .../commons/model/NoteBolt12ZapTest.kt | 4 ++-- .../commons/viewmodels/NestViewModel.kt | 2 +- .../vitorpamplona/quartz/kinds/KindNames.kt | 8 ++++---- .../quartz/nip47WalletConnect/rpc/Request.kt | 2 +- .../bolt12/Bolt12Bech32.kt | 2 +- .../bolt12/Bolt12Merkle.kt | 2 +- .../bolt12/Bolt12Offer.kt | 4 ++-- .../bolt12/Bolt12PayerProof.kt | 4 ++-- .../bolt12/Bolt12ProofBuilder.kt | 2 +- .../bolt12/Tlv.kt | 2 +- .../builder/Bolt12ZapBuilder.kt | 10 +++++----- .../intent/Bolt12ZapIntentEvent.kt | 12 +++++------ .../intent/TagArrayBuilderExt.kt | 8 ++++---- .../offer/Bolt12OfferListEvent.kt | 6 +++--- .../tags/AmountTag.kt | 4 ++-- .../tags/DescriptionTag.kt | 2 +- .../tags/OfferTag.kt | 4 ++-- .../tags/PayerTag.kt | 2 +- .../tags/ProofTag.kt | 4 ++-- .../tags/ZapIdTag.kt | 2 +- .../verify/Bolt12ProofResult.kt | 2 +- .../verify/Bolt12ProofVerifier.kt | 8 ++++---- .../verify/Bolt12ZapValidation.kt | 4 ++-- .../verify/Bolt12ZapValidator.kt | 18 ++++++++--------- .../zap/Bolt12ZapEvent.kt | 16 +++++++-------- .../zap/TagArrayBuilderExt.kt | 12 +++++------ .../quartz/utils/EventFactory.kt | 6 +++--- .../quartz/nip47WalletConnect/RequestTest.kt | 12 +++++------ .../Bolt12ZapEventTest.kt | 8 ++++---- .../bolt12/Bolt12Bech32Test.kt | 2 +- .../bolt12/Bolt12MerkleTest.kt | 2 +- .../bolt12/TlvTest.kt | 2 +- .../builder/Bolt12ZapBuilderTest.kt | 8 ++++---- .../offer/Bolt12OfferListEventTest.kt | 4 ++-- .../verify/Bolt12PayerProofVectorTest.kt | 6 +++--- .../verify/Bolt12ProofFixture.kt | 16 +++++++-------- .../verify/Bolt12ZapValidatorTest.kt | 6 +++--- 82 files changed, 200 insertions(+), 200 deletions(-) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/{nipXXBolt12Zaps => nipB1Bolt12Zaps}/bolt12/Bolt12Bech32.kt (98%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/{nipXXBolt12Zaps => nipB1Bolt12Zaps}/bolt12/Bolt12Merkle.kt (99%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/{nipXXBolt12Zaps => nipB1Bolt12Zaps}/bolt12/Bolt12Offer.kt (95%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/{nipXXBolt12Zaps => nipB1Bolt12Zaps}/bolt12/Bolt12PayerProof.kt (98%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/{nipXXBolt12Zaps => nipB1Bolt12Zaps}/bolt12/Bolt12ProofBuilder.kt (99%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/{nipXXBolt12Zaps => nipB1Bolt12Zaps}/bolt12/Tlv.kt (99%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/{nipXXBolt12Zaps => nipB1Bolt12Zaps}/builder/Bolt12ZapBuilder.kt (93%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/{nipXXBolt12Zaps => nipB1Bolt12Zaps}/intent/Bolt12ZapIntentEvent.kt (94%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/{nipXXBolt12Zaps => nipB1Bolt12Zaps}/intent/TagArrayBuilderExt.kt (90%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/{nipXXBolt12Zaps => nipB1Bolt12Zaps}/offer/Bolt12OfferListEvent.kt (95%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/{nipXXBolt12Zaps => nipB1Bolt12Zaps}/tags/AmountTag.kt (94%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/{nipXXBolt12Zaps => nipB1Bolt12Zaps}/tags/DescriptionTag.kt (97%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/{nipXXBolt12Zaps => nipB1Bolt12Zaps}/tags/OfferTag.kt (94%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/{nipXXBolt12Zaps => nipB1Bolt12Zaps}/tags/PayerTag.kt (97%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/{nipXXBolt12Zaps => nipB1Bolt12Zaps}/tags/ProofTag.kt (94%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/{nipXXBolt12Zaps => nipB1Bolt12Zaps}/tags/ZapIdTag.kt (97%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/{nipXXBolt12Zaps => nipB1Bolt12Zaps}/verify/Bolt12ProofResult.kt (97%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/{nipXXBolt12Zaps => nipB1Bolt12Zaps}/verify/Bolt12ProofVerifier.kt (97%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/{nipXXBolt12Zaps => nipB1Bolt12Zaps}/verify/Bolt12ZapValidation.kt (97%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/{nipXXBolt12Zaps => nipB1Bolt12Zaps}/verify/Bolt12ZapValidator.kt (95%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/{nipXXBolt12Zaps => nipB1Bolt12Zaps}/zap/Bolt12ZapEvent.kt (93%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/{nipXXBolt12Zaps => nipB1Bolt12Zaps}/zap/TagArrayBuilderExt.kt (87%) rename quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/{nipXXBolt12Zaps => nipB1Bolt12Zaps}/Bolt12ZapEventTest.kt (95%) rename quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/{nipXXBolt12Zaps => nipB1Bolt12Zaps}/bolt12/Bolt12Bech32Test.kt (98%) rename quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/{nipXXBolt12Zaps => nipB1Bolt12Zaps}/bolt12/Bolt12MerkleTest.kt (98%) rename quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/{nipXXBolt12Zaps => nipB1Bolt12Zaps}/bolt12/TlvTest.kt (99%) rename quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/{nipXXBolt12Zaps => nipB1Bolt12Zaps}/builder/Bolt12ZapBuilderTest.kt (94%) rename quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/{nipXXBolt12Zaps => nipB1Bolt12Zaps}/offer/Bolt12OfferListEventTest.kt (96%) rename quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/{nipXXBolt12Zaps => nipB1Bolt12Zaps}/verify/Bolt12PayerProofVectorTest.kt (97%) rename quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/{nipXXBolt12Zaps => nipB1Bolt12Zaps}/verify/Bolt12ProofFixture.kt (91%) rename quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/{nipXXBolt12Zaps => nipB1Bolt12Zaps}/verify/Bolt12ZapValidatorTest.kt (98%) 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/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 0193080cf8..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 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 c0a35d5f47..5e95a66bc4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -370,9 +370,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 @@ -1456,7 +1456,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 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 7b60f85867..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 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 7b345d1da4..9ef7f5122d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -392,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 @@ -401,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 @@ -473,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. */ @@ -1294,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) } } @@ -2718,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), 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/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/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/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 2844127ec5..72acbfdd53 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 @@ -1219,7 +1219,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( 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/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/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 f4ac0d4f2c..b168e843e4 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 @@ -84,11 +84,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 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/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/cli/README.md b/cli/README.md index 00dd84a862..b6577ed3e3 100644 --- a/cli/README.md +++ b/cli/README.md @@ -672,7 +672,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 e785b1e2e0..83eb894d9d 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -62,7 +62,7 @@ Status legend: ✅ shipped · 📦 logic lives in `commons/`, needs a command · | Buzz workspaces (`amy buzz`) | ✅ | `BuzzCommands` — post/read the kind:40002 stream timeline, `attest` (offline NIP-OA), `console` (decrypt+aggregate kind:44200 turn metrics via the shared `AgentFleetAggregator`), `personas` (kind:30175). Join/leave reuse `amy relaygroup` (Buzz workspaces are NIP-29 groups). | | 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 5880237780..172139c893 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/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/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/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/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/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..cea7ea4a43 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -386,6 +386,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 +398,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 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 From e934d41bb2eeacfe1b2fc147cdd8cb75c20d58db Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 26 Jul 2026 17:18:26 -0400 Subject: [PATCH 10/15] fix(tor): self-heal a guard sample that is rotten at runtime, not just on disk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tor wedged across app restarts with ~87% of relay connections failing, and neither existing recovery fired. Captured from the device: guards.json default: 60 guards, 59 disabled, 1 unlisted -> 1 usable Arti log AllGuardsDown { n_accepted: 0, n_rejected: 60 } x21,490 sockets 36,007 failures vs 5,276 successful opens Everything above Tor degraded with it. Concord was the visible casualty: its control-plane sync drained exactly 12 wraps on every launch and never folded, so the Messages tab showed zero Concord rows for two restarts running. Two recoveries exist and this state slipped between both: - `ArtiGuardState.hasNoUsableGuards` required `usable == 0`. Its own KDoc claimed "a single usable guard is enough to recover" — the device disproved it. One survivor that is merely *unreachable* is useless for recovery but sufficient to veto it, so the wipe never ran. - `TorManager`'s watchdog only arms while status sits at Connecting. Tor had bootstrapped: the SOCKS proxy was bound, `hasEverBootstrapped` was true, ~13% of connections still worked, so status reached Active and the watchdog never fired. It is built for "Tor never came up"; this is "Tor came up and its guards rotted". Nothing observed the steady-state failure rate, so all three gates evaluated the same way on every launch and `guards.json` carried the wedge forward forever. 1. Proportional disk rule. A sample of at least MIN_SAMPLE_TO_JUDGE_RATIO whose usable guards fall under 1/USABLE_RATIO_DIVISOR of the total is wedged. Below that size only the strict `usable == 0` rule applies, so a young sample Arti is still filling is never wiped out from under a legitimate first bootstrap. 2. Runtime detector. `TorService` counts Arti's own AllGuardsDown log lines (GUARDS_DOWN_THRESHOLD within GUARDS_DOWN_WINDOW_MS) and exposes `TorBackend.guardsDownSignal`; `TorManager` routes it through the same rate-limited self-heal to `resetWithCleanState()`. This is the general fix: it believes Arti when it says every guard was rejected, so it fires even while `guards.json` still looks healthy and status is Active — precisely the blind spot between the two older heuristics. The count lives in the log callback because that is the only place Arti surfaces it; the reset stays in TorManager, which owns the cadence. Verified on the wedged device. Next launch, unprompted: W TorService: No usable Arti guards left on disk — wiping state to rebuild the guard sample guard sample 60 total / 1 usable -> 20 total / 20 usable / 0 disabled AllGuardsDown 21,569 -> 0 sockets 36,007 fail / 5,276 -> 444 fail / 232 open Concord wraps 12, 12, 12 (pinned) -> 12 -> 37 -> 258 Concord rows 0 -> 4 and climbing Tests cover the 59-of-60 field case, a small-sample false-positive guard, and a healthy-majority sample. The pre-existing 22-guard "poisoned but not wedged" fixture still passes, so the ratio does not regress the earlier variant. Co-Authored-By: Claude Opus 5 (1M context) --- .../amethyst/ui/tor/ArtiGuardState.kt | 40 +++++++++-- .../amethyst/ui/tor/TorBackend.kt | 13 ++++ .../amethyst/ui/tor/TorManager.kt | 18 +++++ .../amethyst/ui/tor/TorService.kt | 55 +++++++++++++++ .../amethyst/ui/tor/ArtiGuardStateTest.kt | 69 +++++++++++++++++++ .../amethyst/ui/tor/TorManagerTest.kt | 7 ++ 6 files changed, 195 insertions(+), 7 deletions(-) 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/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() { From 6bf3c11173bb44f3cccc041cd8ca095a9e6a887e Mon Sep 17 00:00:00 2001 From: vitorpamplona <532031+vitorpamplona@users.noreply.github.com> Date: Sun, 26 Jul 2026 21:18:45 +0000 Subject: [PATCH 11/15] chore: sync Crowdin translations and seed translator npub placeholders --- amethyst/src/main/res/values-cs/strings.xml | 326 ++++----- .../src/main/res/values-de-rDE/strings.xml | 290 ++++---- .../src/main/res/values-hi-rIN/strings.xml | 6 + .../src/main/res/values-pl-rPL/strings.xml | 11 +- .../src/main/res/values-pt-rBR/strings.xml | 298 ++++---- .../src/main/res/values-sl-rSI/strings.xml | 679 +++++++++++++++++- .../src/main/res/values-sv-rSE/strings.xml | 294 ++++---- docs/changelog/translators.json | 12 +- 8 files changed, 1302 insertions(+), 614 deletions(-) diff --git a/amethyst/src/main/res/values-cs/strings.xml b/amethyst/src/main/res/values-cs/strings.xml index 534254ffd9..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 @@ -4431,167 +4594,4 @@ Anonymní — jednorázová identita pro každou oblast, nikdy váš npub. Kompatibilní s Bitchatem; zasáhne blízké uživatele na stejných relayích. Veřejné a dočasné — žádná historie, může to číst kdokoli v buňce. - Konzole agenta - Upozorní vás, když vaše připojená peněženka obdrží platbu, která není Nostr zapem - Přijato %1$s sats - Přijaté platby - Nové platby - Podrobnosti souboru - Nebo vložte adresu serveru - Synchronizace souborů už probíhá. Zkuste import znovu, až skončí. - Vypnout vše - Zapnout vše - Prohledejte ostatní Blossom servery a najděte soubory, které jste nahráli jinde, a zkopírujte je na své vlastní servery. - Spravovat mé servery - Importovat soubory… - Nejprve přidejte vlastní Blossom servery, aby bylo kam importované soubory zkopírovat. - Na vybraných serverech nebyly nalezeny žádné nové soubory. - Prohledat servery - Prohledávání… - Tento server je nedostupný - Servery k prohledání - Importovat soubory - Další akce - Není na všech serverech - Na všech serverech - Uloženo na - Nabídka BOLT12 (lno1…) - 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. - Zaplatit připojenou peněženkou - Částka (sats) - Platba BOLT12 selhala: %1$s - Platba BOLT12 odeslána - Chyba zapu BOLT12 - Zaplaceno přes BOLT12, ale důkaz o platbě se nepodařilo ověřit, takže nebyla zveřejněna žádná zap účtenka. - Zaplaceno přes BOLT12, ale peněženka nevrátila žádný důkaz, takže nebyla zveřejněna žádná zap účtenka. - Pracuji… - Plátno (Markdown) - Upravit plátno - V tomto pracovním prostoru zatím nebylo sdíleno žádné plátno. - Uložit plátno - Plátno - Přidat lidi do tohoto pracovního prostoru - Náklady · Persony · Živý pozorovatel - Konzole agenta - (rozdíl zkrácen) - Přidat někoho (npub nebo hex) - Přidat člena - Neplatný npub nebo hex klíč - Přidat někoho do této konverzace - Soukromé konverzace ve vašich pracovních prostorech - Začněte soukromou konverzaci s kýmkoli v pracovním prostoru Buzz. - Zatím žádné přímé zprávy - Skrýt konverzaci - Jen vy - Více - Zprávy Buzz - Nová zpráva - Otevírání… - Komu - Odebrat - Zatím žádné konverzace - Zahájit konverzaci - Přímé zprávy - Pracovní prostor - Upravit - Úprava zprávy - O čem chcete diskutovat? - Nové téma fóra - Zatím žádné odpovědi. Buďte první, kdo odpoví. - Odeslat téma - Vlákno fóra - Přidat - Přidat vše - Přidáno - 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. - Nenalezeny žádné kanály - Hledání vašich kanálů… - Importovat kanály pracovního prostoru - Vaše kanály v tomto pracovním prostoru - Dokončete přijetí podmínek v prohlížeči a pak se vraťte — váš pracovní prostor tu na vás bude čekat. - 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 - Kopírovat - Vytvořit odkaz s pozvánkou - Pozvánku se nepodařilo vytvořit - Platnost této pozvánky vypršela. Požádejte o novou. - Připojit se k tomuto pracovnímu prostoru - Toto nevypadá jako platný odkaz s pozvánkou Buzz. - Odkaz s pozvánkou - Otevřít mé pracovní prostory - Sdílet - Pozvánka do pracovního prostoru - Pracovní prostor - (upraveno) - Připnout kanál - Několik lidí píše… - %1$s píše… - %1$s a %2$s píší… - Odepnout kanál - Procházet skupiny - 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. - Zatím žádné pracovní prostory - Kde lidé a agenti tvoří společně - Opustit pracovní prostor - Vaše pracovní prostory - Pracovní prostory Buzz - Zobrazit - %1$s vás přidal do tohoto kanálu na %2$s. Zobrazit ho ve Zprávách? - Ignorovat - Opustit - Přidáno do %1$s - Někdo - Vy: %1$s - Odeslat obrázek - Úprava zprávy - Smazat nabídku BOLT12 - Upravit - Neplatná nabídka BOLT12 - (upraveno) - Nejsou nastaveny žádné nabídky BOLT12. Přidejte jednu níže ↓ - ŽIVĚ - - %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ů - - - Zobrazit další %1$d konverzaci - Zobrazit všechny %1$d konverzace - Zobrazit všech %1$d konverzace - Zobrazit všech %1$d konverzací - - - %1$d kanál - %1$d kanály - %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 skupina - %1$d skupiny - %1$d skupiny - %1$d skupin - diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 60e8f5674b..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 @@ -4279,149 +4424,4 @@ Anonym — eine Wegwerf-Identität pro Gebiet, niemals dein npub. Bitchat-kompatibel; erreicht Nutzer in der Nähe auf denselben Relays. Öffentlich und flüchtig — kein Verlauf, jeder in der Zelle kann es lesen. - Agent-Konsole - Benachrichtigt dich, wenn deine verbundene Wallet eine Zahlung erhält, die kein Nostr-Zap ist - %1$s sats erhalten - Erhaltene Zahlungen - Neue Zahlungen - Dateidetails - Oder füge eine Serveradresse ein - Eine Dateisynchronisierung läuft bereits. Versuche den Import erneut, sobald sie abgeschlossen ist. - Alle deaktivieren - Alle aktivieren - Durchsuche andere Blossom-Server nach Dateien, die du anderswo hochgeladen hast, und kopiere sie auf deine eigenen Server. - Meine Server verwalten - Dateien importieren… - Füge zuerst eigene Blossom-Server hinzu, damit die importierten Dateien irgendwohin kopiert werden können. - Auf den ausgewählten Servern wurden keine neuen Dateien gefunden. - Server prüfen - Wird geprüft… - Dieser Server ist nicht erreichbar - Zu prüfende Server - Dateien importieren - Weitere Aktionen - Nicht auf allen Servern - Auf allen Servern - Gespeichert auf - BOLT12-Angebot (lno1…) - 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. - Mit verbundener Wallet bezahlen - Betrag (sats) - BOLT12-Zahlung fehlgeschlagen: %1$s - BOLT12-Zahlung gesendet - BOLT12-Zap-Fehler - Über BOLT12 bezahlt, aber der Zahlungsnachweis konnte nicht validiert werden, daher wurde keine Zap-Quittung veröffentlicht. - Über BOLT12 bezahlt, aber die Wallet hat keinen Nachweis zurückgegeben, daher wurde keine Zap-Quittung veröffentlicht. - Arbeitet… - Canvas bearbeiten - In diesem Workspace wurde noch kein Canvas geteilt. - Canvas speichern - Personen zu diesem Workspace hinzufügen - Kosten · Personas · Live-Beobachter - Agent-Konsole - (Diff gekürzt) - Jemanden hinzufügen (npub oder hex) - Mitglied hinzufügen - Kein gültiger npub- oder Hex-Schlüssel - Jemanden zu dieser DM hinzufügen - Private Unterhaltungen in deinen Workspaces - Beginne eine private Unterhaltung mit jemandem in einem Buzz-Workspace. - Noch keine Direktnachrichten - Unterhaltung ausblenden - Nur du - Mehr - Buzz-DMs - Neue Nachricht - Wird geöffnet… - An - Entfernen - Noch keine Unterhaltungen - Unterhaltung beginnen - Direktnachrichten - Bearbeiten - Nachricht bearbeiten - Worüber möchtest du diskutieren? - Neues Forumsthema - Noch keine Antworten. Sei die erste Person, die antwortet. - Thema veröffentlichen - Forum-Thread - Hinzufügen - Alle hinzufügen - Hinzugefügt - 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. - Keine Kanäle gefunden - Deine Kanäle werden gesucht… - Workspace-Kanäle importieren - Deine Kanäle in diesem Workspace - Schließe die Zustimmung zu den Bedingungen im Browser ab und komm dann zurück — dein Workspace wartet auf dich. - 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 - Kopieren - Einladungslink erstellen - Einladung konnte nicht erstellt werden - Diese Einladung ist abgelaufen. Bitte um eine neue. - Diesem Workspace beitreten - Das sieht nicht nach einem gültigen Buzz-Einladungslink aus. - Einladungslink - Meine Workspaces öffnen - Rolle - Teilen - Workspace-Einladung - (bearbeitet) - Kanal anheften - Mehrere Personen tippen… - %1$s tippt… - %1$s und %2$s tippen… - Kanal nicht mehr anheften - Gruppen durchsuchen - Buzz-Workspaces sind Relays, in denen Menschen und KI-Agenten gemeinsam bauen. Tritt einem bei, um ihn hier zu sehen. - Noch keine Workspaces - Wo Menschen und Agenten gemeinsam bauen - Workspace verlassen - Deine Workspaces - Buzz-Workspaces - Anzeigen - %1$s hat dich auf %2$s zu diesem Kanal hinzugefügt. In Nachrichten anzeigen? - Ignorieren - Verlassen - Zu %1$s hinzugefügt - Jemand - Du: %1$s - Bild senden - Nachricht bearbeiten - BOLT12-Angebot löschen - Bearbeiten - Kein gültiges BOLT12-Angebot - (bearbeitet) - Keine BOLT12-Angebote festgelegt. Füge unten eines hinzu ↓ - - %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 - - - %1$d weitere Unterhaltung anzeigen - Alle %1$d Unterhaltungen anzeigen - - - %1$d Kanal - %1$d Kanäle - - - %1$d Workspace - %1$d Workspaces - - - %1$d Gruppe - %1$d Gruppen - 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 f9c600f564..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 @@ -4277,153 +4426,4 @@ Anônimo — uma identidade descartável por área, nunca seu npub. Compatível com Bitchat; alcança usuários próximos nos mesmos relays. Público e efêmero — sem histórico, qualquer um na célula pode ler. - Console do agente - Notifica você quando sua carteira conectada recebe um pagamento que não é um zap do Nostr - Recebidos %1$s sats - Pagamentos recebidos - Novos pagamentos - Repostagens - Detalhes do arquivo - Ou cole um endereço de servidor - Uma sincronização de arquivos já está em andamento. Tente importar novamente quando ela terminar. - Desativar todos - Ativar todos - Procure em outros servidores Blossom por arquivos que você enviou em outro lugar e copie-os para os seus próprios servidores. - Gerenciar meus servidores - Importar arquivos… - Adicione primeiro seus próprios servidores Blossom, para que haja onde copiar os arquivos importados. - Nenhum arquivo novo foi encontrado nos servidores selecionados. - Verificar servidores - Verificando… - Não foi possível acessar este servidor - Servidores a verificar - Importar arquivos - Mais ações - Não está em todos os servidores - Em todos os servidores - Armazenado em - Oferta BOLT12 (lno1…) - 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ê. - Pagar com a carteira conectada - Valor (sats) - Falha no pagamento BOLT12: %1$s - Pagamento BOLT12 enviado - Erro no zap BOLT12 - Pago via BOLT12, mas o comprovante de pagamento não foi validado, então nenhum recibo de zap foi publicado. - Pago via BOLT12, mas a carteira não retornou nenhum comprovante, então nenhum recibo de zap foi publicado. - Trabalhando… - Editar canvas - Nenhum canvas foi compartilhado neste espaço de trabalho ainda. - Salvar canvas - Adicionar pessoas a este espaço de trabalho - Custos · Personas · Observador ao vivo - Console do agente - (diff truncado) - Adicionar alguém (npub ou hex) - Adicionar membro - Não é uma chave npub ou hex válida - Adicionar alguém a esta DM - Conversas privadas nos seus espaços de trabalho - Inicie uma conversa privada com qualquer pessoa em um espaço de trabalho Buzz. - Nenhuma mensagem direta ainda - Ocultar conversa - Só você - Mais - DMs do Buzz - Nova mensagem - Abrindo… - Para - Remover - Nenhuma conversa ainda - Iniciar conversa - Mensagens diretas - Espaço de trabalho - Editar - Editando mensagem - O que você quer discutir? - Novo tópico do fórum - Nenhuma resposta ainda. Seja a primeira pessoa a responder. - Publicar tópico - Discussão do fórum - Adicionar - Adicionar todos - Adicionado - 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. - Nenhum canal encontrado - Procurando seus canais… - Importar canais do espaço de trabalho - Seus canais neste espaço de trabalho - Termine de aceitar os termos no navegador e depois volte — seu espaço de trabalho estará esperando. - 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 - Copiar - Criar link de convite - Não foi possível criar o convite - Este convite expirou. Peça um novo. - Entrar neste espaço de trabalho - Isso não parece ser um link de convite válido do Buzz. - Link de convite - Abrir meus espaços de trabalho - Função - Compartilhar - Convite para espaço de trabalho - Espaço de trabalho - (editado) - Fixar canal - Várias pessoas estão digitando… - %1$s está digitando… - %1$s e %2$s estão digitando… - Desafixar canal - Explorar grupos - Espaços de trabalho Buzz são relays onde humanos e agentes de IA constroem juntos. Entre em um para vê-lo aqui. - Nenhum espaço de trabalho ainda - Onde humanos e agentes constroem juntos - Sair do espaço de trabalho - Seus espaços de trabalho - Espaços de trabalho Buzz - Mostrar - %1$s adicionou você a este canal em %2$s. Mostrar em Mensagens? - Ignorar - Sair - Adicionado a %1$s - Alguém - Você: %1$s - Enviar imagem - Editando mensagem - Excluir oferta BOLT12 - Editar - Não é uma oferta BOLT12 válida - (editado) - Nenhuma oferta BOLT12 definida. Adicione uma abaixo ↓ - AO VIVO - - %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 - - - Ver mais %1$d conversa - Ver todas as %1$d conversas - - - %1$d canal - %1$d canais - - - %1$d espaço de trabalho - %1$d espaços de trabalho - - - %1$d grupo - %1$d grupos - diff --git a/amethyst/src/main/res/values-sl-rSI/strings.xml b/amethyst/src/main/res/values-sl-rSI/strings.xml index 4c5f3fbda1..cd3ad0dbcf 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. @@ -2694,9 +3151,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 +3421,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 +3438,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 +3504,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 +3562,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 +4025,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 +4222,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 +4286,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 +4422,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 +4481,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 76da1d936a..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 @@ -4278,151 +4425,4 @@ Anonym — en engångsidentitet per område, aldrig din npub. Bitchat-kompatibel; når närliggande användare på samma reläer. Offentlig och flyktig — ingen historik, alla i cellen kan läsa den. - Agentkonsol - Meddelar dig när din anslutna plånbok tar emot en betalning som inte är en Nostr-zap - Tog emot %1$s sats - Mottagna betalningar - Nya betalningar - Filinformation - Eller klistra in en serveradress - En filsynkronisering pågår redan. Försök importera igen när den är klar. - Inaktivera alla - Aktivera alla - Sök på andra Blossom-servrar efter filer du laddat upp någon annanstans och kopiera dem till dina egna servrar. - Hantera mina servrar - Importera filer… - Lägg till dina egna Blossom-servrar först, så att de importerade filerna har någonstans att kopieras till. - Inga nya filer hittades på de valda servrarna. - Sök på servrar - Söker… - Kunde inte nå den här servern - Servrar att söka på - Importera filer - Fler åtgärder - Inte på alla servrar - På alla servrar - Lagrad på - BOLT12-erbjudande (lno1…) - 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. - Betala med ansluten plånbok - Belopp (sats) - BOLT12-betalningen misslyckades: %1$s - BOLT12-betalning skickad - BOLT12-zapfel - Betalt via BOLT12, men betalningsbeviset kunde inte valideras, så inget zapkvitto publicerades. - Betalt via BOLT12, men plånboken returnerade inget bevis, så inget zapkvitto publicerades. - Arbetar… - Redigera canvas - Ingen canvas har delats i den här arbetsytan än. - Spara canvas - Lägg till personer i den här arbetsytan - Kostnader · Personas · Liveobservatör - Agentkonsol - (diff avkortad) - Lägg till någon (npub eller hex) - Lägg till medlem - Inte en giltig npub- eller hexnyckel - Lägg till någon i det här DM:et - Privata konversationer i dina arbetsytor - Starta en privat konversation med vem som helst i en Buzz-arbetsyta. - Inga direktmeddelanden än - Dölj konversation - Bara du - Mer - Buzz-DM - Nytt meddelande - Öppnar… - Till - Ta bort - Inga konversationer än - Starta konversation - Direktmeddelanden - Arbetsyta - Redigera - Redigerar meddelande - Vad vill du diskutera? - Nytt forumämne - Inga svar än. Bli först med att svara. - Publicera ämne - Forumtråd - Lägg till - Lägg till alla - Tillagd - 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. - Inga kanaler hittades - Hittar dina kanaler… - Importera arbetsytans kanaler - Dina kanaler i den här arbetsytan - Slutför godkännandet av villkoren i webbläsaren och kom sedan tillbaka — din arbetsyta väntar. - 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 - Kopiera - Skapa inbjudningslänk - Kunde inte skapa inbjudan - Den här inbjudan har gått ut. Be om en ny. - Gå med i den här arbetsytan - Det här ser inte ut som en giltig Buzz-inbjudningslänk. - Inbjudningslänk - Öppna mina arbetsytor - Roll - Dela - Arbetsyteinbjudan - Arbetsyta - (redigerat) - Fäst kanal - Flera personer skriver… - %1$s skriver… - %1$s och %2$s skriver… - Lossa kanal - Bläddra bland grupper - 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. - Inga arbetsytor än - Där människor och agenter bygger tillsammans - Lämna arbetsytan - Dina arbetsytor - Buzz-arbetsytor - Visa - %1$s lade till dig i den här kanalen på %2$s. Visa den i Meddelanden? - Ignorera - Lämna - Tillagd i %1$s - Någon - Du: %1$s - Skicka bild - Redigerar meddelande - Ta bort BOLT12-erbjudande - Redigera - Inte ett giltigt BOLT12-erbjudande - (redigerat) - Inga BOLT12-erbjudanden angivna. Lägg till ett nedan ↓ - - %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 - - - Visa %1$d konversation till - Visa alla %1$d konversationer - - - %1$d kanal - %1$d kanaler - - - %1$d arbetsyta - %1$d arbetsytor - - - %1$d grupp - %1$d grupper - 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": [ From 75a5e91681abdde406388572030b6f60611d12f9 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 26 Jul 2026 17:52:12 -0400 Subject: [PATCH 12/15] fix(quartz): route kind-39005 by tag shape so Buzz thread summaries stop parsing as NIP-29 pin lists kind:39005 is claimed by two unrelated relay-signed addressable events, and EventFactory mapped it unconditionally to NIP-29's GroupPinnedEvent, leaving Buzz's ThreadSummaryEvent unreachable: GroupPinnedEvent d = group id, e = pinned message ids, no h, empty content ThreadSummaryEvent d = thread root id, e = that root, h = channel, JSON content Nothing throws on the mismatch, so a summary would have parsed as a pin list and pinnedEventIds() would have reported the thread root as a pinned message. Discriminate inside the kind's block on the `h` tag, following the kind-20001 BitChat/Buzz presence precedent already in this file. `h` is a safe signal in both directions: the whole NIP-29 relay-generated 39xxx family (metadata, admins, members, participants, supported-roles, pinned) is addressed by `d` alone and never emits `h`, and neither builder does either, so both classes round-trip through the factory on outbound signing as well. Buzz publishes summaries live to channel subscribers (not only over its HTTP bridge), so this is what makes it safe for a channel-scoped filter to ask for 39005 at all. Co-Authored-By: Claude Opus 5 (1M context) --- .../quartz/utils/EventFactory.kt | 22 +++++++++++- .../quartz/nip29RelayGroups/PinEventsTest.kt | 35 +++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) 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 cea7ea4a43..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 @@ -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/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)) From 4cb0fb64afda9ecf8b3661316bafeb4eeac952cf Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 26 Jul 2026 23:52:33 +0200 Subject: [PATCH 13/15] fix: decode numeric HTML entities in meta tag content Link previews left " / " literal because replaceCharRefs only whitelisted named entities. Parse terminated numeric refs as code points. Fixes #3723 --- .../commons/preview/MetaTagsParser.kt | 26 +++++++++-- .../preview/MetaTagsParserCharRefTest.kt | 45 +++++++++++++++++++ 2 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/preview/MetaTagsParserCharRefTest.kt 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/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")) + } +} From f17205b01556ac1f0be071047c79c4a5920e45a0 Mon Sep 17 00:00:00 2001 From: vitorpamplona <532031+vitorpamplona@users.noreply.github.com> Date: Sun, 26 Jul 2026 21:57:06 +0000 Subject: [PATCH 14/15] chore: sync Crowdin translations and seed translator npub placeholders --- amethyst/src/main/res/values-sl-rSI/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/amethyst/src/main/res/values-sl-rSI/strings.xml b/amethyst/src/main/res/values-sl-rSI/strings.xml index cd3ad0dbcf..6a388b3ae1 100644 --- a/amethyst/src/main/res/values-sl-rSI/strings.xml +++ b/amethyst/src/main/res/values-sl-rSI/strings.xml @@ -2363,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 From 8df98c34df821c276385ce581e4aba8a92fb09ab Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 26 Jul 2026 18:19:01 -0400 Subject: [PATCH 15/15] feat(buzz): say who joined and what changed in the kind-40099 system lines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The in-chat markers read "member joined" / "visibility changed" — the relay's raw payload type with the underscores swapped for spaces. Everything that makes the line useful was parsed and then dropped: which member, who added them, and what the setting changed TO. Render the full sentence from the signed payload instead, with the pubkeys resolved to the names the viewer knows them by and the avatar of whoever the line is about: member joined -> "Shawn was added by straycat" (actor != target) -> "Vitor joined" (self-join, actor == target) member removed -> "Bob was removed by Alice" visibility changed -> "straycat made this channel open — anyone can find and join it" -> "... made this channel private — invite only" ttl changed -> "Alice set messages to disappear after 7 days" / turned off topic/purpose -> the new value, or "cleared the topic" when blank channel created/deleted/archived/restored, message deleted (+ public reason), dm created -> named after their actor The event is signed by the RELAY keypair, so the people come from the payload's actor/target, never from note.author. Membership lines are about the member, so that is whose avatar shows and whose profile the pill opens; everything else is about the actor. Also covers the neighbouring timeline narration, which had the same problem: huddle joins/leaves now name the participant from the `p` tag instead of "someone joined the huddle", job lines name the signer, and forum votes name the voter. All of these move from hardcoded English into string resources. Quartz's SystemMessagePayload was missing target_event_id, action_id, reason_code, public_reason and participants — every field of the moderation tombstone and the DM-created payload. The KDoc now carries the complete vocabulary read off the relay's emit_system_message callers, and the type strings are constants so the UI cannot typo a branch into dead code. An unknown type still renders as "alice: some_new_type" rather than vanishing, so a relay that grows new vocabulary stays legible. Verified on emulator-5554 against nosfabrica.communities.buzz.xyz: the four "was added by" lines, "straycat created this channel", "Matthias Debernardini joined" and "straycat made this channel open" all render with the right subject avatar, in the chat and in the Messages-list preview. Co-Authored-By: Claude Opus 5 (1M context) --- .../loggedIn/chats/feed/ChatMessageCompose.kt | 6 +- .../chats/feed/layouts/ChatSystemMessage.kt | 36 ++- .../chats/feed/types/RenderBuzzNotes.kt | 118 +++++----- .../feed/types/RenderBuzzSystemMessage.kt | 207 ++++++++++++++++++ .../chats/rooms/ChatroomHeaderCompose.kt | 4 +- amethyst/src/main/res/values/strings.xml | 42 ++++ .../buzz/stream/SystemMessagePayload.kt | 65 +++++- .../buzz/stream/SystemMessageEventTest.kt | 73 ++++++ 8 files changed, 487 insertions(+), 64 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderBuzzSystemMessage.kt 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/rooms/ChatroomHeaderCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt index 42a3fe8650..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,7 +456,7 @@ 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. 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 @@ -604,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/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index ebaa131c93..167a715f3d 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3447,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/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/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) + } }