From 3d65d7ec643a4077a5bfa4319aad7cf0f6ad5255 Mon Sep 17 00:00:00 2001 From: Dan Barker <1181564+danbarker@users.noreply.github.com> Date: Tue, 10 Nov 2026 08:44:55 +0000 Subject: [PATCH] Show "Approved" on an invite ask card approved from its tracker item here Approving an agent's invite-link ask from its tracker item showed the link once, but the chat card then read "Approved on another device": the mirror item's close cannot tell a tap on this phone from one on another device. The tracker item's answer names the ask it approved. The session's invite link store now keeps, in memory only, the ids of asks approved on this device (never the link), and the chat card reads "Approved" for those. Co-Authored-By: Claude Opus 5.5 --- .../chat/matron/android/AppDependencies.kt | 4 +- .../android/features/chat/ChatScreen.kt | 6 +- .../android/features/chat/TimelineItemView.kt | 4 +- .../chat/matron/android/journal/Contacts.kt | 14 +++- .../chat/matron/android/journal/ItemsApi.kt | 9 ++- .../chat/matron/android/journal/ItemsSync.kt | 7 +- .../chat/matron/android/journal/JournalApi.kt | 6 +- .../android/viewmodels/ChatViewModel.kt | 20 +++-- .../matron/android/journal/ContactsApiTest.kt | 1 + .../matron/android/journal/ItemsSyncTest.kt | 20 ++++- .../viewmodels/InviteAskViewModelTest.kt | 79 ++++++++++++++++++- 11 files changed, 152 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/chat/matron/android/AppDependencies.kt b/app/src/main/java/chat/matron/android/AppDependencies.kt index 73f45fa..a199b20 100644 --- a/app/src/main/java/chat/matron/android/AppDependencies.kt +++ b/app/src/main/java/chat/matron/android/AppDependencies.kt @@ -346,8 +346,8 @@ class AppDependencies( api, db, dbFile, store, engine, maintenance, itemsSync, missionsSync, memoriesSync, pinsSync = pinsSync, coordinatorSync = coordinatorSync, ) - core.itemsSync.onInviteLink = { link -> - core.inviteLinks.show(link, chat.matron.android.journal.RevealedInviteLinks.Source.ASK) + core.itemsSync.onInviteLink = { link, askId -> + core.inviteLinks.show(link, chat.matron.android.journal.RevealedInviteLinks.Source.ASK, askId) } cores[session.userID] = core // Nothing proportional to store history runs on the launch path any diff --git a/app/src/main/java/chat/matron/android/features/chat/ChatScreen.kt b/app/src/main/java/chat/matron/android/features/chat/ChatScreen.kt index 87ed039..b6ff268 100644 --- a/app/src/main/java/chat/matron/android/features/chat/ChatScreen.kt +++ b/app/src/main/java/chat/matron/android/features/chat/ChatScreen.kt @@ -812,6 +812,7 @@ private fun TimelineRowView( val grantKeepLevels by chatVM.grantKeepLevels.collectAsStateWithLifecycle() val inviteAskStates by chatVM.inviteAskStates.collectAsStateWithLifecycle() val inviteAskResolutions by chatVM.inviteAskResolutions.collectAsStateWithLifecycle() + val inviteAsksApprovedHere by chatVM.inviteAsksApprovedHere.collectAsStateWithLifecycle() // A bridge queued_release lands as a hidden row, so no visible row changes; // collecting the memo is what recomposes the sibling queue cards' buttons // (apple #162; Bugbot #48). @@ -894,7 +895,10 @@ private fun TimelineRowView( onGrantKeepLevelChange = { eventID, level -> chatVM.setGrantKeepLevel(eventID, level) }, inviteAsks = InviteAskHost( // The flows read here are what make the card recompose. - state = { eventID -> inviteAskStates.let { inviteAskResolutions }.let { chatVM.inviteAskState(eventID) } }, + state = { eventID, ask -> + inviteAskStates.let { inviteAskResolutions }.let { inviteAsksApprovedHere } + .let { chatVM.inviteAskState(eventID, ask) } + }, answer = { eventID, ask, approve -> chatVM.answerInviteAsk(eventID, ask, approve) }, ), ) diff --git a/app/src/main/java/chat/matron/android/features/chat/TimelineItemView.kt b/app/src/main/java/chat/matron/android/features/chat/TimelineItemView.kt index 2abf27d..0974a7b 100644 --- a/app/src/main/java/chat/matron/android/features/chat/TimelineItemView.kt +++ b/app/src/main/java/chat/matron/android/features/chat/TimelineItemView.kt @@ -345,7 +345,7 @@ private fun RenderedBody( CappedCard(maxWidth = 360.dp) { ContactInviteAskCard( ask = kind.ask, - state = inviteAsks?.state?.invoke(kind.eventID) ?: InviteAskState.Unavailable, + state = inviteAsks?.state?.invoke(kind.eventID, kind.ask) ?: InviteAskState.Unavailable, onApprove = { inviteAsks?.answer?.invoke(kind.eventID, kind.ask, true) }, onDecline = { inviteAsks?.answer?.invoke(kind.eventID, kind.ask, false) }, ) @@ -611,6 +611,6 @@ data class TappedImage(val url: String?, val model: Any, val name: String? = nul /// What an invite ask card in the timeline reads and calls: its state and /// the answer. An approved link is shown by the shell, not the card. class InviteAskHost( - val state: (eventID: String) -> InviteAskState, + val state: (eventID: String, ask: ContactInviteAsk) -> InviteAskState, val answer: (eventID: String, ask: ContactInviteAsk, approve: Boolean) -> Unit, ) diff --git a/app/src/main/java/chat/matron/android/journal/Contacts.kt b/app/src/main/java/chat/matron/android/journal/Contacts.kt index 28c93b5..22d89dd 100644 --- a/app/src/main/java/chat/matron/android/journal/Contacts.kt +++ b/app/src/main/java/chat/matron/android/journal/Contacts.kt @@ -402,7 +402,18 @@ class RevealedInviteLinks(val scope: kotlinx.coroutines.CoroutineScope) { /// The links waiting to be shown, oldest first: the first is on screen. val current: kotlinx.coroutines.flow.StateFlow> = _pending - fun show(link: String, source: Source) { + private val _approvedHere = kotlinx.coroutines.flow.MutableStateFlow>(emptySet()) + + /// The ids of the agents' asks this device approved in this session, + /// whichever screen the tap was on (the chat card or the ask's tracker + /// item), so the chat card reads "Approved" rather than "Approved on + /// another device". Ids only, never the link; never persisted. + val approvedHere: kotlinx.coroutines.flow.StateFlow> = _approvedHere + + /// Queues [link] to be shown once. [askId] names the agent's ask it + /// answered, when there is one. + fun show(link: String, source: Source, askId: String? = null) { + askId?.let { id -> _approvedHere.update { it + id } } _pending.update { it + Reveal(link, source) } } @@ -416,5 +427,6 @@ class RevealedInviteLinks(val scope: kotlinx.coroutines.CoroutineScope) { fun clearAll() { _pending.value = emptyList() + _approvedHere.value = emptySet() } } diff --git a/app/src/main/java/chat/matron/android/journal/ItemsApi.kt b/app/src/main/java/chat/matron/android/journal/ItemsApi.kt index 1ac28b9..01526f8 100644 --- a/app/src/main/java/chat/matron/android/journal/ItemsApi.kt +++ b/app/src/main/java/chat/matron/android/journal/ItemsApi.kt @@ -63,7 +63,14 @@ data class ItemDetail(val item: TrackerItem, val comments: List) /// [link] is an invite link, on the answer to an Approve tap on an agent's /// invite ask only — the https form when the journal sends it, else the /// `matron://` one: shown once on this device and never stored. -data class ItemCommentResult(val item: TrackerItem, val comment: TrackerComment, val link: String? = null) +/// [link] and [askId] come back only from an Approve tap on an agent's +/// invite ask mirror: the link to show once, and which ask it answered. +data class ItemCommentResult( + val item: TrackerItem, + val comment: TrackerComment, + val link: String? = null, + val askId: String? = null, +) /// `POST /items` body. [awaiting] is tri-state like the Swift `ItemAwaiting??`: /// [awaitingSet] false ⇒ key omitted (server default), true with a null diff --git a/app/src/main/java/chat/matron/android/journal/ItemsSync.kt b/app/src/main/java/chat/matron/android/journal/ItemsSync.kt index f57ce58..37cf50d 100644 --- a/app/src/main/java/chat/matron/android/journal/ItemsSync.kt +++ b/app/src/main/java/chat/matron/android/journal/ItemsSync.kt @@ -185,9 +185,10 @@ class ItemsSync( private var stopped = false /// Receives the invite link an Approve tap on an agent's invite ask - /// mirror brings back, to show once. Never stored here. + /// mirror brings back, to show once, with the id of the ask it answered + /// (when the journal names it). Never stored here. @Volatile - var onInviteLink: ((String) -> Unit)? = null + var onInviteLink: ((link: String, askId: String?) -> Unit)? = null private val _isSupported = MutableStateFlow(true) override val isSupported: StateFlow = _isSupported.asStateFlow() @@ -674,7 +675,7 @@ class ItemsSync( // outbox — without this the reply would be invisible // until the detail screen is reopened. store.commitOutboxResult(result.item, result.comment, deletingLocalID = row.localID) - result.link?.let { link -> onInviteLink?.invoke(link) } + result.link?.let { link -> onInviteLink?.invoke(link, result.askId) } refreshItem(itemID) } ItemOutboxEntity.OP_CREATE -> { diff --git a/app/src/main/java/chat/matron/android/journal/JournalApi.kt b/app/src/main/java/chat/matron/android/journal/JournalApi.kt index bdf1a51..f8e4b9c 100644 --- a/app/src/main/java/chat/matron/android/journal/JournalApi.kt +++ b/app/src/main/java/chat/matron/android/journal/JournalApi.kt @@ -1073,7 +1073,11 @@ class JournalApi( ?: throw JournalApiError.Transport("malformed comment response") // A tap on an invite ask's mirror answers with the link, this once: // the https form when the journal sends it, else the matron:// one. - return ItemCommentResult(decodeItem(obj), comment, link = InviteLinks.shareableOf(obj)) + return ItemCommentResult( + decodeItem(obj), comment, + link = InviteLinks.shareableOf(obj), + askId = obj.objectOrNull("ask")?.stringOrNull("id")?.takeIf { it.isNotEmpty() }, + ) } override suspend fun closeItem(id: String, resolution: ItemResolution, comment: String?): TrackerItem = diff --git a/app/src/main/java/chat/matron/android/viewmodels/ChatViewModel.kt b/app/src/main/java/chat/matron/android/viewmodels/ChatViewModel.kt index 88e474b..0a5a392 100644 --- a/app/src/main/java/chat/matron/android/viewmodels/ChatViewModel.kt +++ b/app/src/main/java/chat/matron/android/viewmodels/ChatViewModel.kt @@ -802,9 +802,16 @@ class ChatViewModel( private val _inviteAskResolutions = MutableStateFlow>(emptyMap()) val inviteAskResolutions: StateFlow> = _inviteAskResolutions.asStateFlow() - /// Render state for one ask card: this device's own answer, then the - /// timeline's record, then Idle. - fun inviteAskState(eventID: String): InviteAskState { + /// The asks this device approved this session, from the chat card or + /// the ask's tracker item. Collected by the chat so a tracker-item tap + /// redraws the card. + val inviteAsksApprovedHere: StateFlow> = + inviteLinks?.approvedHere ?: MutableStateFlow>(emptySet()).asStateFlow() + + /// Render state for one ask card: this device's own answer (on the card, + /// or on [ask]'s tracker item this session), then the timeline's record, + /// then Idle. + fun inviteAskState(eventID: String, ask: ContactInviteAsk? = null): InviteAskState { _inviteAskStates.value[eventID]?.let { if (it !is InviteAskState.Idle) return it } val local = inviteAnswers[eventID] when (local) { @@ -812,6 +819,9 @@ class ChatViewModel( "decline" -> return InviteAskState.Declined EXPIRED_ANSWER -> return InviteAskState.Expired } + // Approved on this device's tracker item: the mirror's close (and a + // later card tap's `not_pending`) cannot tell that from another device. + if (ask != null && ask.askId in inviteAsksApprovedHere.value) return InviteAskState.Approved _inviteAskResolutions.value[eventID]?.let { return it } if (local == INVITE_ELSEWHERE) return InviteAskState.ApprovedElsewhere return if (inviteAsks == null) InviteAskState.Unavailable else InviteAskState.Idle @@ -821,12 +831,12 @@ class ChatViewModel( /// [inviteLinks], which shows it once. fun answerInviteAsk(eventID: String, ask: ContactInviteAsk, approve: Boolean) { val answerer = inviteAsks ?: return - if (!inviteAskState(eventID).answerable) return + if (!inviteAskState(eventID, ask).answerable) return _inviteAskStates.value = _inviteAskStates.value + (eventID to InviteAskState.Sending) (inviteLinks?.scope ?: scope).launch { try { val answer = answerer.answerInviteAsk(ask.askId, approve) - answer.link?.let { inviteLinks?.show(it, RevealedInviteLinks.Source.ASK) } + answer.link?.let { inviteLinks?.show(it, RevealedInviteLinks.Source.ASK, ask.askId) } rememberInviteAnswer(eventID, if (approve) "approve" else "decline") _inviteAskStates.value = _inviteAskStates.value - eventID } catch (cancel: CancellationException) { diff --git a/app/src/test/java/chat/matron/android/journal/ContactsApiTest.kt b/app/src/test/java/chat/matron/android/journal/ContactsApiTest.kt index 432de18..8aab10d 100644 --- a/app/src/test/java/chat/matron/android/journal/ContactsApiTest.kt +++ b/app/src/test/java/chat/matron/android/journal/ContactsApiTest.kt @@ -151,6 +151,7 @@ class ContactsApiTest { "ask":{"id":"ia_1","state":"approved"},"invite":{"id":"inv_1"},"link":"$link"}""")) val result = api().commentItem("it_1", "Approve", emptyList(), idempotencyKey = null, action = "Approve") assertEquals(link, result.link) + assertEquals("ia_1", result.askId) assertEquals("it_1", result.comment.itemID) } diff --git a/app/src/test/java/chat/matron/android/journal/ItemsSyncTest.kt b/app/src/test/java/chat/matron/android/journal/ItemsSyncTest.kt index 91c13cc..449e632 100644 --- a/app/src/test/java/chat/matron/android/journal/ItemsSyncTest.kt +++ b/app/src/test/java/chat/matron/android/journal/ItemsSyncTest.kt @@ -157,9 +157,14 @@ private class FakeItems : ItemsProviding { synchronized(lock) { _commentErrorForItemID[id] }?.let { throw it } if (failComments) throw JournalApiError.Transport("offline") val item = TrackerItem(id = id, num = 1, kind = ItemKind.QUESTION, awaiting = ItemAwaiting.AGENT, title = "Q", originConvoID = "c1") - return ItemCommentResult(item, TrackerComment("ic_srv", id, ItemAuthor.USER, body = body)) + val answer = inviteAnswer + return ItemCommentResult(item, TrackerComment("ic_srv", id, ItemAuthor.USER, body = body), answer?.first, answer?.second) } + /// (link, ask id) every comment call answers with, as an Approve tap on + /// an invite ask's mirror does. + @Volatile var inviteAnswer: Pair? = null + override suspend fun closeItem(id: String, resolution: ItemResolution, comment: String?): TrackerItem = error("unused") override suspend fun reopenItem(id: String, comment: String?): TrackerItem = error("unused") override suspend fun rankItem(id: String, change: ItemRankChange): TrackerItem = error("unused") @@ -431,6 +436,19 @@ class ItemsSyncTest { rig.sync.stop() } + @Test + fun anInviteAskApproveHandsOverTheLinkAndTheAskItAnswered() = runBlocking { + val api = FakeItems(); api.inviteAnswer = "matron://contact?v=1" to "ia_1" + val rig = make(api) + val received = java.util.concurrent.CopyOnWriteArrayList>() + rig.sync.onInviteLink = { link, askId -> received.add(link to askId) } + rig.sync.start() + rig.sync.enqueueComment("it_1", "L1", "Approve", emptyList(), action = "Approve") + waitUntil { received.isNotEmpty() } + assertEquals(listOf("matron://contact?v=1" to "ia_1"), received.toList()) + rig.sync.stop() + } + @Test fun outboxDrainsOnRunningAndDeletesOnSuccess() = runBlocking { val api = FakeItems(); api.failComments = true diff --git a/app/src/test/java/chat/matron/android/viewmodels/InviteAskViewModelTest.kt b/app/src/test/java/chat/matron/android/viewmodels/InviteAskViewModelTest.kt index 40a7f03..c8ece47 100644 --- a/app/src/test/java/chat/matron/android/viewmodels/InviteAskViewModelTest.kt +++ b/app/src/test/java/chat/matron/android/viewmodels/InviteAskViewModelTest.kt @@ -2,6 +2,15 @@ package chat.matron.android.viewmodels import chat.matron.android.chat.FakeMediaService import chat.matron.android.chat.FakeTimelineService +import chat.matron.android.chat.JournalTimelineMapper +import chat.matron.android.chat.TimelineItem +import chat.matron.android.journal.JournalEvent +import chat.matron.android.journal.parseJsonObjectOrNull +import java.time.Instant +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject +import okhttp3.HttpUrl.Companion.toHttpUrl import chat.matron.android.events.ContactInviteAsk import chat.matron.android.events.InviteAskState import chat.matron.android.journal.ContactRefusal @@ -51,7 +60,28 @@ class InviteAskViewModelTest { store: InMemoryKeyValueStore, answerer: InviteAskAnswering?, links: RevealedInviteLinks = RevealedInviteLinks(scope), - ) = ChatViewModel("!r:s", FakeTimelineService(), FakeMediaService(), scope, store, inviteAsks = answerer, inviteLinks = links) + timeline: FakeTimelineService = FakeTimelineService(), + ) = ChatViewModel("!r:s", timeline, FakeMediaService(), scope, store, inviteAsks = answerer, inviteLinks = links) + + private fun event(seq: Long, type: String, payload: kotlinx.serialization.json.JsonObject): TimelineItem = + JournalTimelineMapper.timelineItem( + JournalEvent(seq, "!r:s", Instant.ofEpochSecond(seq), "agent:box-a", type, payload), + "user:alice", "https://example.com".toHttpUrl(), + )!! + + /// The ask card (seq 10) and its tracker mirror's close (seq 12), the + /// way the journal records an approve made on the mirror item. + private fun approvedTimeline(): FakeTimelineService = FakeTimelineService().apply { + val card = event(10, "permission_request", parseJsonObjectOrNull( + """{"kind":"contact_invite","direction":"out","ask_id":"ia_1","from_name":"box-a"}""", + )!!) + val close = event(12, "item", buildJsonObject { + put("item_id", "it_1"); put("num", 1); put("kind", "question"); put("title", "Approve making an invite link") + put("by", "user"); put("action", "closed"); put("resolution", "decided"); put("consent", "invite") + putJsonObject("comment") { put("id", "c12"); put("body", "Approved — the link was shown on the device you tapped on.") } + }) + snapshotsToEmit = listOf(listOf(card, close)) + } @Test fun readOnlyWithNoAnswerer() = vmTest { scope -> @@ -107,4 +137,51 @@ class InviteAskViewModelTest { assertEquals("You already have 20 open invites. Withdraw one first.", state.message) assertTrue(state.answerable) } + + @Test + fun anApproveOnTheTrackerItemHereReadsAsApprovedOnTheCard() = vmTest { scope -> + val links = RevealedInviteLinks(scope) + val vm = vm(scope, InMemoryKeyValueStore(), Answerer(), links, approvedTimeline()) + vm.start().join() + waitUntil { vm.inviteAskResolutions.value.isNotEmpty() } + // The tracker item's Approve came back with the link on this device. + links.show(link, RevealedInviteLinks.Source.ASK, askId = "ia_1") + assertEquals(InviteAskState.Approved, vm.inviteAskState("10", ask)) + assertEquals("Approved", vm.inviteAskState("10", ask).resolvedText) + } + + @Test + fun anApproveFromAnotherDeviceStillReadsAsElsewhere() = vmTest { scope -> + val links = RevealedInviteLinks(scope) + val vm = vm(scope, InMemoryKeyValueStore(), Answerer(), links, approvedTimeline()) + vm.start().join() + waitUntil { vm.inviteAskResolutions.value.isNotEmpty() } + // Another ask approved here does not make this one ours. + links.show(link, RevealedInviteLinks.Source.ASK, askId = "ia_other") + assertEquals(InviteAskState.ApprovedElsewhere, vm.inviteAskState("10", ask)) + assertEquals("Approved on another device.", vm.inviteAskState("10", ask).resolvedText) + } + + @Test + fun aCardTapAfterApprovingOnTheTrackerItemHereReadsAsApproved() = vmTest { scope -> + val links = RevealedInviteLinks(scope) + links.show(link, RevealedInviteLinks.Source.ASK, askId = "ia_1") + val vm = vm(scope, InMemoryKeyValueStore(), Answerer(error = ContactRefusal(409, "not_pending")), links) + assertEquals(InviteAskState.Approved, vm.inviteAskState("10", ask)) + } + + @Test + fun theApprovedHereSetIsMemoryOnlyAndKeepsNoLink() = vmTest { scope -> + val store = InMemoryKeyValueStore() + val links = RevealedInviteLinks(scope) + links.show(link, RevealedInviteLinks.Source.ASK, askId = "ia_1") + assertEquals(setOf("ia_1"), links.approvedHere.value) + links.done(links.current.value.single()) + // Done drops the link, never the fact that it was approved here. + assertEquals(setOf("ia_1"), links.approvedHere.value) + vm(scope, store, Answerer(), links).inviteAskState("10", ask) + assertTrue(store.getStringList("matron.inviteAskAnswers.!r:s").orEmpty().isEmpty()) + links.clearAll() + assertTrue(links.approvedHere.value.isEmpty()) + } }