feat(chat): delivery ticks for own Concord channel messages

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129yvP2hmVeDFfuKKy94tqX
This commit is contained in:
Claude
2026-07-15 04:55:34 +00:00
parent a2a3a60ee7
commit 5069ff2c7a
2 changed files with 43 additions and 0 deletions
@@ -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
@@ -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<NormalizedRelayUrl>,
) {
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<ChatDelivery?> =
synchronized(lock) {
flowForLocked(noteId)