Keep sent messages visible until received
This commit is contained in:
1 parent
3c19b5a9bb
commit
3c6e6778fd
5 files changed
+266
-33
No files matched your search
@@ -349,10 +349,20 @@ fun SessionScreen(
|
||||
// the newest end and then travelling to the anchor is exactly the journey a reader must never
|
||||
// see.
|
||||
var restoring by remember(address, epoch) { mutableStateOf(savedAnchor != null) }
|
||||
// Messages the server has taken and the session has not read yet, by the id that will resolve
|
||||
// them. From the event stream rather than from what this screen sent, so they survive leaving
|
||||
// the session -- and a message sent from another device is drawn waiting on this one too.
|
||||
var queued by remember { mutableStateOf(listOf<QueuedMessage>()) }
|
||||
// Messages not yet recorded as received by the provider. The local ones bridge Send to the
|
||||
// server's first durable event (and hold a network failure); the rest are reconstructed from
|
||||
// that event stream, so another device's queued message is drawn here too.
|
||||
val pendingKey =
|
||||
remember(settings, address) { "${settings.host}:${settings.port}/${address.cachePath}" }
|
||||
var queued by remember(pendingKey) { mutableStateOf(loadPendingMessages(context, pendingKey)) }
|
||||
|
||||
/**
|
||||
* Changes the visible outbox and keeps only its not-yet-durable part across a screen reopen.
|
||||
*/
|
||||
fun replaceQueued(messages: List<QueuedMessage>) {
|
||||
queued = messages
|
||||
savePendingMessages(context, pendingKey, messages)
|
||||
}
|
||||
// Commands the session has been asked to run and cannot yet, by the id that will resolve them.
|
||||
// From the server, so a rename sent from another device is drawn waiting here too.
|
||||
var waitingCommands by remember { mutableStateOf(listOf<Pair<String, String>>()) }
|
||||
@@ -419,7 +429,9 @@ fun SessionScreen(
|
||||
held = listOf()
|
||||
oldestSeq = 0L
|
||||
moreHistory = true
|
||||
queued = listOf()
|
||||
// A send the server could not accept is this phone's only copy. A reset replaces server
|
||||
// state, not that local outbox, so dropping it here would eat the message a second time.
|
||||
replaceQueued(queued.filter { it.local })
|
||||
waitingCommands = listOf()
|
||||
}
|
||||
|
||||
@@ -449,15 +461,15 @@ fun SessionScreen(
|
||||
// Waiting, then read. Matched by id: the same message sent twice is two bubbles, and
|
||||
// clearing by text would take away whichever matched first.
|
||||
if (event is SessionEvent.MessageQueued) {
|
||||
queued = queued + QueuedMessage(event.id, event.text, event.attachments)
|
||||
replaceQueued(reconcileQueuedMessage(queued, event))
|
||||
}
|
||||
if (event is SessionEvent.UserMessage) {
|
||||
queued = queued.filterNot { it.id == event.id }
|
||||
replaceQueued(reconcileUserMessage(queued, event))
|
||||
}
|
||||
// Waiting, then taken back. From the server rather than from the tap, so every device drops
|
||||
// the bubble and a reconnect does not put back one that was cancelled.
|
||||
if (event is SessionEvent.MessageDropped) {
|
||||
queued = queued.filterNot { it.id == event.id }
|
||||
replaceQueued(queued.filterNot { it.id == event.id })
|
||||
}
|
||||
// Waiting, then gone: a command leaves this list when the session takes it, and the row it
|
||||
// becomes is added by `foldEvent` in the same pass.
|
||||
@@ -597,7 +609,8 @@ fun SessionScreen(
|
||||
// what a phone over the tunnel costs, it won the race.
|
||||
//
|
||||
// Guarded here rather than at the two callers because it is a fact about the question.
|
||||
if (oldestSeq == 0L) return false
|
||||
val before = oldestSeq
|
||||
if (before == 0L) return false
|
||||
// The fetch *and* the fold, both off the thread that draws. Only the fetch used to be, and
|
||||
// the fold is the expensive half: `foldEvent` returns a new list per event, so a page is
|
||||
// that many copies of a growing list -- around three hundred thousand element copies, run
|
||||
@@ -607,7 +620,7 @@ fun SessionScreen(
|
||||
// errand. Neither half touches anything the composition owns.
|
||||
val page =
|
||||
withContext(Dispatchers.IO) {
|
||||
val older = source.page(before = oldestSeq, limit = limit, coalesce = coalesce)
|
||||
val older = source.page(before = before, limit = limit, coalesce = coalesce)
|
||||
if (older.isEmpty()) return@withContext null
|
||||
// Folded oldest-first into a list of their own, then put in front: `foldEvent`
|
||||
// merges streaming text into the item before it, so replaying an older page through
|
||||
@@ -620,21 +633,29 @@ fun SessionScreen(
|
||||
}
|
||||
older.first().seq to earlier
|
||||
}
|
||||
// A stream reset can replace the transcript while the page request is out. Its answer is a
|
||||
// page of the view that no longer exists, so it must not decide anything about the new one.
|
||||
if (oldestSeq != before) return false
|
||||
if (page == null) {
|
||||
moreHistory = false
|
||||
return false
|
||||
}
|
||||
val (oldest, earlier) = page
|
||||
oldestSeq = oldest
|
||||
moreHistory = oldestSeq > 1L
|
||||
// Joined here rather than above, because it is the one step that reads what is already
|
||||
// loaded: `items` must be read where it is written.
|
||||
val joined = joinPages(earlier, items)
|
||||
val joinedForWarm = joinPages(earlier, items)
|
||||
// After the join rather than on the page alone: a boundary that fell through a reply leaves
|
||||
// `joinPages` holding a message made of both halves, and that text has existed for no time
|
||||
// at all. Warming the page by itself warmed the two halves and missed the one thing drawn.
|
||||
warm(replies, joined)
|
||||
items = joined
|
||||
warm(replies, joinedForWarm)
|
||||
// `warm` suspends. A live event can be recorded while it parses, so joining the page before
|
||||
// it and then assigning that snapshot afterward used to erase the event from this visit --
|
||||
// the durable transcript still held it, which is why reopening made a sent message return.
|
||||
// Read and write `items` together after the suspension instead.
|
||||
if (oldestSeq != before) return false
|
||||
oldestSeq = oldest
|
||||
moreHistory = oldestSeq > 1L
|
||||
items = joinPages(earlier, items)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1104,7 +1125,7 @@ fun SessionScreen(
|
||||
} catch (e: ApiException) {
|
||||
e.message ?: "this message could not be taken back"
|
||||
}
|
||||
queued = queued.map { if (it.id == messageId) it.copy(refusal = refusal) else it }
|
||||
replaceQueued(queued.map { if (it.id == messageId) it.copy(refusal = refusal) else it })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1184,10 +1205,23 @@ fun SessionScreen(
|
||||
input = atEnd("")
|
||||
saveDraft(context, summary.id, "")
|
||||
pendingAttachments = emptyList()
|
||||
// Nothing is added here. The server says what is waiting -- it emits `messageQueued` when
|
||||
// it takes a message it cannot deliver yet -- and this screen draws that. Holding a local
|
||||
// copy as well was the bug: the two agreed only until the app was restarted.
|
||||
act { sendMessage(settings, summary.id, text, attachments) }
|
||||
// Clearing the field makes one promise: its contents are either waiting in this quiet
|
||||
// bubble or recorded as a user message. The server's `messageQueued` replaces this local
|
||||
// bridge when it arrives; an immediate `userMessage` removes it. Until one does, even a
|
||||
// network failure leaves the words visible with the failure attached to them.
|
||||
val pending = localPendingMessage(text, attachments)
|
||||
replaceQueued(queued + pending)
|
||||
scope.launch {
|
||||
try {
|
||||
withContext(Dispatchers.IO) { sendMessage(settings, summary.id, text, attachments) }
|
||||
replaceQueued(markPendingAccepted(queued, pending.id))
|
||||
actionError = null
|
||||
} catch (e: ApiException) {
|
||||
replaceQueued(
|
||||
markPendingFailure(queued, pending.id, e.message ?: "message not sent")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// One path for everything attached, however it arrived: the photo picker, the file chooser or
|
||||
@@ -1475,7 +1509,9 @@ fun SessionScreen(
|
||||
// produces, not here: the server knows whether the
|
||||
// message was still its to take back, and the other
|
||||
// devices have to be told by the same event.
|
||||
onTakeBack = { takeBack(waiting.id) },
|
||||
onTakeBack =
|
||||
if (waiting.local) null
|
||||
else ({ takeBack(waiting.id) }),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2082,12 +2118,13 @@ private fun UserChunkRow(
|
||||
/**
|
||||
* A message the person holding the phone sent, in a bubble at their end of the conversation.
|
||||
*
|
||||
* [pending] is one the server has taken and the session has not read yet -- drawn quieter, because
|
||||
* "said" and "heard" are different claims and the transcript must not merge them.
|
||||
* [pending] has not yet been recorded as received by the provider -- drawn quieter, because
|
||||
* "pressed Send" and "heard" are different claims and the transcript must not merge them.
|
||||
*
|
||||
* A pending bubble is tappable: [onTakeBack] asks the server to drop the message before the session
|
||||
* reads it, and [refusal] is what came back when it would not. The refusal is drawn here rather
|
||||
* than with the screen's other errors because this is where the reader pressed.
|
||||
* A server-accepted pending bubble is tappable: [onTakeBack] asks the server to drop the message
|
||||
* before the session reads it. A local one is not, because its request may still be in flight.
|
||||
* [refusal] is either that take-back refusal or the send's network failure. It is drawn here rather
|
||||
* than with the screen's other errors because this is the message the failure belongs to.
|
||||
*
|
||||
* A settled message longer than [USER_SPLIT_CHARS] is drawn as [UserChunkRow] slices instead -- one
|
||||
* `Text` holding a pasted log is a hundred-thousand-pixel layout in the frame the row scrolls into.
|
||||
@@ -2165,13 +2202,6 @@ private fun UserBubble(
|
||||
* [refusal] is why taking it back did not work, kept per message rather than on the screen: two
|
||||
* bubbles can be waiting at once, and an error above them both would not say which.
|
||||
*/
|
||||
private data class QueuedMessage(
|
||||
val id: String,
|
||||
val text: String,
|
||||
val attachments: List<String>,
|
||||
val refusal: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Whether a model switch has anything to warn about -- see [ModelSwitchWarning].
|
||||
*
|
||||
|
||||
Reference in new issue
Block a user