feat: relay-acceptance delivery ticks on own chat messages

Ticks are sourced from relay OK acceptances, rendered next to the
timestamp on the last message of each own-author run:

- clock: published but no relay accepted yet
- single check: accepted by at least one relay
- double check (green): DMs - every participant's gift wrap accepted by
  that participant's relays; rooms - all target room relays accepted
- group DMs additionally show a delivered k/n participant count

New ChatDeliveryTracker captures the recipient -> wrap -> target-relays
mapping inside Account.broadcastPrivately (the only place it exists,
before wraps are aliased onto a single note) and room targets in
signAndSendPrivatelyOrBroadcast, then attributes relay OKs back per
recipient via a persistent listener. Messages from before a restart fall
back to the note's seen-on relays.

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-11 04:07:15 +00:00
parent 5a2587c085
commit 5abb89435d
5 changed files with 391 additions and 6 deletions
@@ -127,6 +127,7 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxLoaderState
import com.vitorpamplona.amethyst.model.trustedAssertions.TrustProviderListState
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.relayClient.chatDelivery.ChatDeliveryTracker
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler
import com.vitorpamplona.amethyst.service.uploads.FileHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.EventProcessor
@@ -517,6 +518,10 @@ class Account(
val newNotesPreProcessor = EventProcessor(this, cache)
// Per-message publish acceptance (relay OKs), feeding the delivery ticks on
// own chat bubbles.
val chatDeliveryTracker = ChatDeliveryTracker(client)
val otsState = OtsState(signer, cache, otsResolverBuilder, scope, settings)
val marmotManager: MarmotManager? = mlsGroupStateStore?.let { MarmotManager(signer, it, marmotMessageStore, marmotKeyPackageStore) }
@@ -2423,11 +2428,14 @@ class Account(
val event = signer.sign(template)
cache.justConsumeMyOwnEvent(event)
val relays = relayList(event)
if (!relays.isNullOrEmpty()) {
client.publish(event, relays.toSet())
} else {
client.publish(event, computeRelayListToBroadcast(event))
}
val targets =
if (!relays.isNullOrEmpty()) {
relays.toSet()
} else {
computeRelayListToBroadcast(event)
}
chatDeliveryTracker.trackPublic(event.id, targets)
client.publish(event, targets)
return event
}
@@ -2892,6 +2900,19 @@ class Account(
}
suspend fun broadcastPrivately(signedEvents: NIP17Factory.Result) {
// The recipient -> wrap -> target-relays mapping only exists here, before
// the wraps are aliased onto a single note; capture it for delivery ticks.
signedEvents.wraps.forEach { wrap ->
wrap.recipientPubKey()?.let { recipient ->
chatDeliveryTracker.trackWrap(
displayedNoteId = signedEvents.msg.id,
recipient = recipient,
wrapId = wrap.id,
targetRelays = computeRelayListToBroadcast(wrap),
)
}
}
broadcastPrivately(signedEvents.wraps)
markDmRoomAsRead(signedEvents.msg)
}
@@ -0,0 +1,201 @@
/*
* 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.service.relayClient.chatDelivery
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayInsertConfirmationCollector
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
/** Delivery progress of one recipient's gift wrap (NIP-17 DMs). */
@Immutable
data class RecipientDelivery(
val recipient: HexKey,
val targetRelays: Set<NormalizedRelayUrl>,
val acceptedRelays: Set<NormalizedRelayUrl> = emptySet(),
) {
val isDelivered: Boolean
get() = acceptedRelays.isNotEmpty()
}
/**
* Delivery progress of one outgoing chat message. For DMs [recipients] carries one
* entry per gift wrap (the sender's self-copy included); for public rooms it is
* null and [targetRelays]/[acceptedRelays] describe the room's relay set.
*/
@Immutable
data class ChatDelivery(
val targetRelays: Set<NormalizedRelayUrl>,
val acceptedRelays: Set<NormalizedRelayUrl> = emptySet(),
val recipients: List<RecipientDelivery>? = null,
) {
val isFullyAccepted: Boolean
get() =
if (recipients != null) {
recipients.all { it.isDelivered }
} else {
targetRelays.isNotEmpty() && acceptedRelays.containsAll(targetRelays)
}
}
/**
* Remembers, per outgoing chat message, which relays were targeted at publish time
* and which have accepted (relay OK) since the source for the delivery ticks on
* own chat bubbles.
*
* The relay pool's outbox drops its entry once an event is fully acked, and relay
* OKs for recipient gift wraps aggregate onto a single aliased Note, losing WHICH
* recipient's wrap was accepted. This tracker fills both gaps: sends register the
* `displayed note id -> (recipient, wrap id, target relays)` mapping while it still
* exists (inside the publish loop), and a persistent OK listener attributes each
* acceptance back to the message and, for DMs, to the recipient.
*
* In-memory only: history from before an app restart simply has no entry, and the
* UI falls back to the Note's seen-on relays.
*/
class ChatDeliveryTracker(
client: INostrClient,
) {
private val lock = Any()
private val deliveries = MutableStateFlow(mapOf<HexKey, ChatDelivery>())
// wrap id -> (displayed note id, recipient pubkey)
private var wrapIndex = mapOf<HexKey, Pair<HexKey, HexKey>>()
// insertion order of displayed note ids, for pruning
private val trackedOrder = ArrayDeque<HexKey>()
@Suppress("unused")
private val okCollector =
RelayInsertConfirmationCollector(client) { eventId, relay ->
onAccepted(eventId, relay.url)
}
/** Registers a public room message published to the room's [targetRelays]. */
fun trackPublic(
eventId: HexKey,
targetRelays: Set<NormalizedRelayUrl>,
) {
if (targetRelays.isEmpty()) return
synchronized(lock) {
if (deliveries.value[eventId] == null) {
registerNoteId(eventId)
}
deliveries.value = deliveries.value + (eventId to ChatDelivery(targetRelays))
}
}
/**
* Registers one recipient's gift wrap of the DM whose chat feed shows
* [displayedNoteId] (the inner rumor's id).
*/
fun trackWrap(
displayedNoteId: HexKey,
recipient: HexKey,
wrapId: HexKey,
targetRelays: Set<NormalizedRelayUrl>,
) {
synchronized(lock) {
val current = deliveries.value[displayedNoteId]
if (current == null) {
registerNoteId(displayedNoteId)
}
val recipients = (current?.recipients ?: emptyList()) + RecipientDelivery(recipient, targetRelays)
deliveries.value =
deliveries.value +
(
displayedNoteId to
ChatDelivery(
targetRelays = (current?.targetRelays ?: emptySet()) + targetRelays,
acceptedRelays = current?.acceptedRelays ?: emptySet(),
recipients = recipients,
)
)
wrapIndex = wrapIndex + (wrapId to (displayedNoteId to recipient))
}
}
fun deliveryFlow(noteId: HexKey): Flow<ChatDelivery?> = deliveries.map { it[noteId] }.distinctUntilChanged()
fun currentFor(noteId: HexKey): ChatDelivery? = deliveries.value[noteId]
private fun onAccepted(
eventId: HexKey,
relay: NormalizedRelayUrl,
) {
// Cheap negative path: OKs fire for every event the app publishes anywhere.
val isWrap = wrapIndex[eventId]
if (isWrap == null && deliveries.value[eventId] == null) return
synchronized(lock) {
val wrapTarget = wrapIndex[eventId]
if (wrapTarget != null) {
val (noteId, recipient) = wrapTarget
val delivery = deliveries.value[noteId] ?: return
deliveries.value =
deliveries.value +
(
noteId to
delivery.copy(
acceptedRelays = delivery.acceptedRelays + relay,
recipients =
delivery.recipients?.map {
if (it.recipient == recipient) {
it.copy(acceptedRelays = it.acceptedRelays + relay)
} else {
it
}
},
)
)
} else {
val delivery = deliveries.value[eventId] ?: return
deliveries.value =
deliveries.value + (eventId to delivery.copy(acceptedRelays = delivery.acceptedRelays + relay))
}
}
}
private fun registerNoteId(noteId: HexKey) {
trackedOrder.addLast(noteId)
if (trackedOrder.size > MAX_TRACKED) {
val evicted = trackedOrder.removeFirst()
deliveries.value = deliveries.value - evicted
wrapIndex = wrapIndex.filterValues { it.first != evicted }
}
}
companion object {
// Delivery state is only rendered on recent messages; a bounded window keeps
// the maps from growing for the whole session.
private const val MAX_TRACKED = 500
}
}
@@ -0,0 +1,149 @@
/*
* 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
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
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.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.chatDelivery.ChatDelivery
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Font12SP
import com.vitorpamplona.amethyst.ui.theme.allGoodColor
import com.vitorpamplona.amethyst.ui.theme.placeholderText
/**
* Relay-acceptance ticks for the logged-in user's own chat messages, rendered next
* to the timestamp:
*
* - clock: published, no relay has accepted yet
* - single check: accepted somewhere (at least one relay OK / seen-on relay)
* - double check (green): DMs every participant's gift wrap was accepted by at
* least one of that participant's relays; rooms every target room relay accepted.
*
* For DM group rooms a `k/n` participant count accompanies the ticks. Messages sent
* before an app restart have no tracker entry and fall back to the note's seen-on
* relays (single check when present).
*/
@Composable
fun ChatDeliveryTicks(
baseNote: Note,
accountViewModel: AccountViewModel,
) {
val tracker = accountViewModel.account.chatDeliveryTracker
val delivery by
remember(baseNote) { tracker.deliveryFlow(baseNote.idHex) }
.collectAsStateWithLifecycle(tracker.currentFor(baseNote.idHex))
val seenOnState by
remember(baseNote) { baseNote.flow().relays.stateFlow }
.collectAsStateWithLifecycle()
val seenSomewhere = seenOnState.note.relays.isNotEmpty()
RenderDeliveryTicks(delivery, seenSomewhere)
}
@Composable
private fun RenderDeliveryTicks(
delivery: ChatDelivery?,
seenSomewhere: Boolean,
) {
val pendingColor = MaterialTheme.colorScheme.placeholderText
val deliveredColor = MaterialTheme.colorScheme.allGoodColor
if (delivery == null) {
// Untracked (sent before a restart): the seen-on relay set is the only signal.
if (seenSomewhere) {
TickIcon(MaterialSymbols.Done, R.string.chat_delivery_accepted, pendingColor)
} else {
TickIcon(MaterialSymbols.Schedule, R.string.chat_delivery_pending, pendingColor)
}
return
}
val recipients = delivery.recipients
if (recipients != null && recipients.size > 2) {
// Group DM: double check once everyone got it, plus a delivered count.
val deliveredCount = recipients.count { it.isDelivered }
Row(verticalAlignment = Alignment.CenterVertically) {
when {
deliveredCount == 0 && !seenSomewhere ->
TickIcon(MaterialSymbols.Schedule, R.string.chat_delivery_pending, pendingColor)
delivery.isFullyAccepted ->
TickIcon(MaterialSymbols.DoneAll, R.string.chat_delivery_delivered_all, deliveredColor)
else ->
TickIcon(MaterialSymbols.Done, R.string.chat_delivery_accepted, pendingColor)
}
Text(
text = "$deliveredCount/${recipients.size}",
fontSize = Font12SP,
color = if (delivery.isFullyAccepted) deliveredColor else pendingColor,
maxLines = 1,
)
}
return
}
// 1:1 DMs and public rooms share the classic tick ladder.
val acceptedSomewhere = seenSomewhere || delivery.acceptedRelays.isNotEmpty()
when {
!acceptedSomewhere ->
TickIcon(MaterialSymbols.Schedule, R.string.chat_delivery_pending, pendingColor)
delivery.isFullyAccepted ->
TickIcon(MaterialSymbols.DoneAll, R.string.chat_delivery_delivered_all, deliveredColor)
else ->
TickIcon(MaterialSymbols.Done, R.string.chat_delivery_accepted, pendingColor)
}
}
@Composable
private fun TickIcon(
symbol: MaterialSymbol,
contentDescription: Int,
tint: Color,
) {
Icon(
symbol = symbol,
contentDescription = stringRes(contentDescription),
modifier = Modifier.size(14.dp),
tint = tint,
)
}
@@ -267,7 +267,17 @@ fun NormalChatNote(
},
timeRow =
if (!innerQuote && groupPosition.isLastOfGroup) {
{ ChatTimeAgo(note) }
{
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = RowColSpacing,
) {
ChatTimeAgo(note)
if (isLoggedInUser && !note.isDraft()) {
ChatDeliveryTicks(note, accountViewModel)
}
}
}
} else {
null
},
+4
View File
@@ -2818,6 +2818,10 @@
<string name="add_content_warning">Add content warning</string>
<string name="remove_content_warning">Remove content warning</string>
<string name="chat_delivery_pending">Waiting for a relay to accept this message</string>
<string name="chat_delivery_accepted">Accepted by at least one relay</string>
<string name="chat_delivery_delivered_all">Delivered to all recipients\' relays</string>
<string name="add_expiration_date">Add expiration date</string>
<string name="remove_expiration_date">Remove expiration date</string>
<string name="expiration_date_label">Expiration Date</string>