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 3ec8a23598..a44639e7ca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -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) } 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 new file mode 100644 index 0000000000..052f5197fd --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/chatDelivery/ChatDeliveryTracker.kt @@ -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, + val acceptedRelays: Set = 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, + val acceptedRelays: Set = emptySet(), + val recipients: List? = 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()) + + // wrap id -> (displayed note id, recipient pubkey) + private var wrapIndex = mapOf>() + + // insertion order of displayed note ids, for pruning + private val trackedOrder = ArrayDeque() + + @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, + ) { + 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, + ) { + 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 = 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 + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatDeliveryTicks.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatDeliveryTicks.kt new file mode 100644 index 0000000000..11faa75d41 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatDeliveryTicks.kt @@ -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, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt index a716ea9388..2ae14f5087 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt @@ -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 }, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 05f3fee146..fe644627bb 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2818,6 +2818,10 @@ Add content warning Remove content warning + Waiting for a relay to accept this message + Accepted by at least one relay + Delivered to all recipients\' relays + Add expiration date Remove expiration date Expiration Date