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)