mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 09:13:23 +00:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
+201
@@ -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
|
||||
}
|
||||
}
|
||||
+149
@@ -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,
|
||||
)
|
||||
}
|
||||
+11
-1
@@ -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
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user