From 5069ff2c7a3eee2e3ba68ead35f5f1faf20c1aa8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 04:55:34 +0000 Subject: [PATCH] feat(chat): delivery ticks for own Concord channel messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Concord send path published the encrypted plane wrap but never registered with chatDeliveryTracker, so own messages showed no acceptance ticks. Relays OK the wrap (kind-1059) while the feed row is the decrypted inner rumor, so a plain trackPublic wouldn't match. Added trackWrappedPublic, which maps the wrap id onto the displayed rumor id (relay-only delivery, no per-recipient breakdown — the same ticks a public room gets). The Concord message/image send paths re-open the wrap they just built to key the tracker by the rumor id; reactions and typing wraps are skipped since they never become a feed row. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0129yvP2hmVeDFfuKKy94tqX --- .../vitorpamplona/amethyst/model/Account.kt | 21 ++++++++++++++++++ .../chatDelivery/ChatDeliveryTracker.kt | 22 +++++++++++++++++++ 2 files changed, 43 insertions(+) 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 c1d6d6d6c3..052044895d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -158,6 +158,8 @@ import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite import com.vitorpamplona.quartz.concord.cord05Invites.InviteRelayDictionary +import com.vitorpamplona.quartz.concord.crypto.GroupKey +import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope import com.vitorpamplona.quartz.experimental.bounties.BountyAddValueEvent import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent @@ -2099,6 +2101,7 @@ class Account( else -> ConcordActions.buildChannelMessage(signer, channelKey, channelIdHex, entry.rootEpoch, text, TimeUtils.now(), emojiTags) } + trackConcordDelivery(entry, channelKey, wrap) publishConcordWrap(entry, wrap) return true } @@ -2123,6 +2126,7 @@ class Account( // Carry NIP-30 custom-emoji tags for any `:shortcode:` in the caption, same as a plain message. val emojiTags = emoji.findEmojiTags(text).map { it.toTagArray() }.toTypedArray() val wrap = ConcordActions.buildChannelImageMessage(signer, channelKey, channelIdHex, entry.rootEpoch, text, imetas, TimeUtils.now(), emojiTags) + trackConcordDelivery(entry, channelKey, wrap) publishConcordWrap(entry, wrap) return true } @@ -2233,6 +2237,23 @@ class Account( if (relays.isNotEmpty()) client.publish(wrap, relays) } + /** + * Registers an own Concord channel message with the delivery tracker so its chat + * bubble shows relay-acceptance ticks. Relays OK the encrypted [wrap], but the feed + * shows the inner rumor, so we re-open the wrap (we just built it, so this always + * succeeds) to key the tracker by the rumor id the bubble is drawn from. Reactions + * and typing wraps skip this — they never become a feed row. + */ + private fun trackConcordDelivery( + entry: ConcordCommunityListEntry, + channelKey: GroupKey, + wrap: Event, + ) { + val rumorId = ConcordStreamEnvelope.openOrNull(wrap, channelKey)?.rumor?.id ?: return + val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } + chatDeliveryTracker.trackWrappedPublic(rumorId, wrap.id, relays) + } + // ── Concord roles & moderation (CORD-04) ───────────────────────────────── // Each publishes a Control Plane edition; authority is enforced at fold time by // every client's AuthorityResolver, so a call by someone who doesn't outrank the diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/chatDelivery/ChatDeliveryTracker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/chatDelivery/ChatDeliveryTracker.kt index e4fdb52ef5..7e27dda358 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/chatDelivery/ChatDeliveryTracker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/chatDelivery/ChatDeliveryTracker.kt @@ -149,6 +149,28 @@ class ChatDeliveryTracker( } } + /** + * Registers a group message that was broadcast as a single encrypted [wrapId] to + * the group's [targetRelays] (Concord channels: the whole channel shares one + * plane wrap, not a per-recipient gift wrap). Relays OK the wrap, but the feed + * shows the decrypted inner rumor, so [displayedNoteId] (the rumor id) is mapped + * through [wrapId] — like [trackPublic] (relay ticks, no per-recipient breakdown) + * but keyed by the wrap the relay actually acknowledges. + */ + fun trackWrappedPublic( + displayedNoteId: HexKey, + wrapId: HexKey, + targetRelays: Set, + ) { + if (targetRelays.isEmpty()) return + synchronized(lock) { + flowForLocked(displayedNoteId).value = ChatDelivery(targetRelays) + // recipient is unused for a relay-only delivery (recipients stays null, so + // onAccepted never matches on it); reuse the note id as a harmless value. + wrapIndex = wrapIndex + (wrapId to (displayedNoteId to displayedNoteId)) + } + } + fun deliveryFlow(noteId: HexKey): StateFlow = synchronized(lock) { flowForLocked(noteId)