Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/src/main/java/chat/matron/android/AppDependencies.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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) },
),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) },
)
Expand Down Expand Up @@ -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,
)
14 changes: 13 additions & 1 deletion app/src/main/java/chat/matron/android/journal/Contacts.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<List<Reveal>> = _pending

fun show(link: String, source: Source) {
private val _approvedHere = kotlinx.coroutines.flow.MutableStateFlow<Set<String>>(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<Set<String>> = _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) }
}

Expand All @@ -416,5 +427,6 @@ class RevealedInviteLinks(val scope: kotlinx.coroutines.CoroutineScope) {

fun clearAll() {
_pending.value = emptyList()
_approvedHere.value = emptySet()
}
}
9 changes: 8 additions & 1 deletion app/src/main/java/chat/matron/android/journal/ItemsApi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,14 @@ data class ItemDetail(val item: TrackerItem, val comments: List<TrackerComment>)
/// [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
Expand Down
7 changes: 4 additions & 3 deletions app/src/main/java/chat/matron/android/journal/ItemsSync.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<Boolean> = _isSupported.asStateFlow()
Expand Down Expand Up @@ -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 -> {
Expand Down
6 changes: 5 additions & 1 deletion app/src/main/java/chat/matron/android/journal/JournalApi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
20 changes: 15 additions & 5 deletions app/src/main/java/chat/matron/android/viewmodels/ChatViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -802,16 +802,26 @@ class ChatViewModel(
private val _inviteAskResolutions = MutableStateFlow<Map<String, InviteAskState>>(emptyMap())
val inviteAskResolutions: StateFlow<Map<String, InviteAskState>> = _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<Set<String>> =
inviteLinks?.approvedHere ?: MutableStateFlow<Set<String>>(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) {
"approve" -> return InviteAskState.Approved
"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
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
20 changes: 19 additions & 1 deletion app/src/test/java/chat/matron/android/journal/ItemsSyncTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String?>? = 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")
Expand Down Expand Up @@ -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<Pair<String, String?>>()
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 ->
Expand Down Expand Up @@ -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())
}
}
Loading