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
5 changes: 3 additions & 2 deletions app/src/main/assets/pombo_bridge.html
Original file line number Diff line number Diff line change
Expand Up @@ -1560,6 +1560,7 @@
);
const all = [];
const opener = api._sealedOpener(pwKey);
const drain = { streamId: a.streamId, partition: a.partition };
const partial = await api._drainResend(stream, function (msg) {
if (!a.recoverSigner && !api._envelopeIsAuthentic(msg)) return;
const rec = {
Expand All @@ -1573,7 +1574,7 @@
storageFetch.stamp(rec.meta, msg, a.streamId, a.partition);
all.push(rec);
opener.take(rec);
}, Date.now() + (a.budgetMs || 40000), { streamId: a.streamId, partition: a.partition });
}, Date.now() + (a.budgetMs || 40000), drain);
await opener.done();
// Exhaustion is about what the RANGE held, not about what survived the
// permission filter: a page made only of refused rows still means there
Expand All @@ -1587,7 +1588,7 @@
// A cut-short drain cannot prove exhaustion — keep pagination alive.
return {
messages: all.slice(start), hasMore: fetched > count || partial, partial: partial,
readError: storageFetch.lastReadError(a.streamId, a.partition)
errors: drain.errors, readError: storageFetch.lastReadError(a.streamId, a.partition)
};
},

Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/com/pombo/android/AppViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1216,6 +1216,8 @@ class AppViewModel(app: Application) : AndroidViewModel(app), PomboBridge.Listen
val hasMoreHistory get() = manager.hasMoreHistory
val historyError get() = manager.historyError
val historyRead get() = manager.historyRead
val overridesOwed get() = manager.overridesOwed
val pagingNudge get() = manager.pagingNudge
val waitingForKeys get() = manager.waitingForKeys
val loadingHistory get() = manager.loadingHistory
val isPreview get() = manager.isPreview
Expand Down
95 changes: 85 additions & 10 deletions app/src/main/java/com/pombo/android/ChannelManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -421,7 +421,10 @@ class ChannelManager(
val channel = _current.value?.takeIf { it.messageStreamId == messageStreamId }
?: return@withContext
Log.i(TAG, "refreshing history")
loadHistory(channel, switchGeneration)
val generation = switchGeneration
if (!loadHistory(channel, generation) && _historyRead.value == HistoryRead.OK) {
retryOpenReads(channel, generation)
}
// The -3 artifacts fetched at open were epoch-sealed and
// unreadable until this key arrived — pins/moderation and a
// hidden channel's image need their own re-pull (the admin
Expand Down Expand Up @@ -4873,7 +4876,19 @@ class ChannelManager(
}
val loads = launch {
listOf(
launch { if (!loadHistory(channel, generation)) retryOpenReads(channel, generation) },
launch {
// The gate below lets go at its safety net; the
// empty state waits for this read instead.
if (stillCurrent(generation)) _historyRead.value = HistoryRead.READING
val readAll = try {
loadHistory(channel, generation)
} finally {
if (stillCurrent(generation) && _historyRead.value == HistoryRead.READING) {
_historyRead.value = HistoryRead.OK
}
}
if (!readAll) retryOpenReads(channel, generation)
},
launch { loadAdminState(channel, generation) }
).joinAll()
}
Expand Down Expand Up @@ -5002,6 +5017,8 @@ class ChannelManager(
_loadingHistory.value = false
_historyError.value = null
_historyRead.value = HistoryRead.OK
_overridesOwed.value = false
resetPagingBackoff()
// Both are per-channel verdicts — carrying them across a switch
// shows the previous room's key/subscription state on this one.
_waitingForKeys.value = false
Expand Down Expand Up @@ -5101,6 +5118,7 @@ class ChannelManager(
adminPollJob?.cancel(); adminPollJob = null
memberCatchUpJob?.cancel(); memberCatchUpJob = null
historyRetryJob?.cancel(); historyRetryJob = null
resetPagingBackoff()
_current.value = null
_isPreview.value = false
unsubscribeAll(channel)
Expand Down Expand Up @@ -5428,19 +5446,26 @@ class ChannelManager(
private class HistoryPage(
val entries: JSONArray, val contents: List<Any?>, val readError: HistoryError? = null,
/** The node broke the read off without answering it: what came is applied, the read is owed. */
val failed: Boolean = false
val failed: Boolean = false,
/** The drain stopped before the page ended, by its budget or a break, with no refusal to explain it. */
val cut: Boolean = false
)

private val HistoryPage?.answered get() = this != null && !failed
/** Overrides owe every row of their page: one cut by its budget leaves edits and deletions unread. */
private val HistoryPage?.whole get() = answered && this?.cut != true

private val _historyError = MutableStateFlow<HistoryError?>(null)
/** The refusal behind an empty history, for the empty state to explain. */
val historyError: StateFlow<HistoryError?> = _historyError.asStateFlow()

/** Whether the open channel's reads came back, are being read again, or gave up. */
enum class HistoryRead { OK, RETRYING, FAILED }
/** Whether the open channel's reads came back, are still out, are being read again, or gave up. */
enum class HistoryRead { OK, READING, RETRYING, FAILED }
private val _historyRead = MutableStateFlow(HistoryRead.OK)
val historyRead: StateFlow<HistoryRead> = _historyRead.asStateFlow()
private val _overridesOwed = MutableStateFlow(false)
/** The open channel's edits and deletions did not come back with its messages. */
val overridesOwed: StateFlow<Boolean> = _overridesOwed.asStateFlow()
private var historyRetryJob: Job? = null
@Volatile private var historyRetryWake: CompletableDeferred<Unit>? = null
internal var historyRetryDelaysMs: LongArray = HISTORY_RETRY_DELAYS_MS
Expand Down Expand Up @@ -5545,8 +5570,9 @@ class ChannelManager(
}
// A 4xx is the node's answer about this read; any other break
// (network, 5xx, a page refused for its storedAt) left it unread.
val failed = res.optInt("errors", 0) > 0 && (readError == null || readError.status !in 400..499)
HistoryPage(arr, contents, readError, failed)
val refused = readError != null && readError.status in 400..499
val failed = res.optInt("errors", 0) > 0 && !refused
HistoryPage(arr, contents, readError, failed, cut = res.optBoolean("partial", false) && !refused)
}
} catch (e: Exception) {
Log.w(TAG, "history ${channel.name} P$partition: read failed: ${e.message}")
Expand Down Expand Up @@ -5593,8 +5619,10 @@ class ChannelManager(
onNoStorage = { noStorage += "content" }
)
}
// The content's ceiling: nothing is applied before both pages are
// back, so this only lengthens an open whose node is slow on P1 alone.
val o = async {
fetchHistoryPage(channel, StreamConstants.P_CONTROL, 50, 20_000, 30_000,
fetchHistoryPage(channel, StreamConstants.P_CONTROL, 50, 45_000, 60_000,
onNoStorage = { noStorage += "overrides" })
}
// Reactions moved to the -5 — without this read a reopened channel
Expand Down Expand Up @@ -5693,8 +5721,9 @@ class ChannelManager(
// without a `messages` array, which skipped this sweep and left any
// override that had arrived before its target parked forever.
applyPendingOverrides()
return (content.answered || "content" in noStorage) &&
(overrides.answered || "overrides" in noStorage) && sideReadsOk
val overridesRead = overrides.whole || "overrides" in noStorage
_overridesOwed.value = !overridesRead
return (content.answered || "content" in noStorage) && overridesRead && sideReadsOk
}

/** Moves the pagination cursor back. Chunks count too — they carry no id. */
Expand All @@ -5703,6 +5732,42 @@ class ChannelManager(
if (ts > 0L && (oldestTimestamp == 0L || ts < oldestTimestamp)) oldestTimestamp = ts
}

private var pagingFailures = 0
private var pagingRetryAt = 0L
private var pagingNudgeJob: Job? = null
internal var pagingRetryDelaysMs: LongArray = HISTORY_RETRY_DELAYS_MS
private val _pagingNudge = MutableStateFlow(0)
/** Ticks when an older page that came back incomplete may be asked for again. */
val pagingNudge: StateFlow<Int> = _pagingNudge.asStateFlow()

private fun resetPagingBackoff() {
pagingNudgeJob?.cancel(); pagingNudgeJob = null
pagingFailures = 0
pagingRetryAt = 0L
}

/** Asks for the page again after a backoff; past the last one, only the user's scroll does. */
private fun backOffPaging(generation: Int) {
val wait = pagingRetryDelaysMs[minOf(pagingFailures, pagingRetryDelaysMs.size - 1)]
pagingFailures++
pagingRetryAt = System.currentTimeMillis() + wait
pagingNudgeJob?.cancel(); pagingNudgeJob = null
if (pagingFailures > pagingRetryDelaysMs.size) return
pagingNudgeJob = scope.launch {
delay(wait)
if (stillCurrent(generation)) _pagingNudge.value++
}
}

/** Whether a range page is the node's answer: a refusal is; a break is not, nor a cut where every row counts. */
private fun rangeAnswered(page: JSONObject?, cutCounts: Boolean): Boolean {
page ?: return false
val status = readErrorOf(page)?.status
if (status != null && status in 400..499) return true
if (page.optInt("errors", 0) > 0) return false
return !cutCounts || !page.optBoolean("partial", false)
}

/**
* Loads the previous page of history (web: channels.js loadMoreHistory).
* Fetches content (P0) and overrides (P1) older than the current cursor;
Expand All @@ -5716,6 +5781,7 @@ class ChannelManager(
// A page that failed keeps paging open, so an empty screen would ask
// again every frame; the open's reads are the retry's until they are back.
if (_historyRead.value != HistoryRead.OK) return 0
if (System.currentTimeMillis() < pagingRetryAt) return 0

// Cursor of Date.now() when history held only reactions/chunks (web parity).
val before = if (oldestTimestamp > 0L) oldestTimestamp else System.currentTimeMillis()
Expand Down Expand Up @@ -5757,6 +5823,15 @@ class ChannelManager(

// Discard if the user switched channels while we were fetching.
if (!stillCurrent(generationAtStart)) return 0
// Taken whole or not at all: content without its overrides paints
// edits and deletions undone, and the cursor would move past
// overrides that nothing reads again.
if (!rangeAnswered(content, cutCounts = false) || !rangeAnswered(overrides, cutCounts = true)) {
Log.w(TAG, "loadMore ${channel.name}: page came back incomplete, asking again later")
backOffPaging(generationAtStart)
return 0
}
resetPagingBackoff()

// Content first, then overrides — an edit/delete needs its target
// present, so each partition gets its own batch rather than one
Expand Down
25 changes: 20 additions & 5 deletions app/src/main/java/com/pombo/android/ui/screens/ChatScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,8 @@ fun ChatScreen(vm: AppViewModel) {
val hasMoreHistory by vm.hasMoreHistory.collectAsState()
val historyError by vm.historyError.collectAsState()
val historyRead by vm.historyRead.collectAsState()
val overridesOwed by vm.overridesOwed.collectAsState()
val pagingNudge by vm.pagingNudge.collectAsState()
val loadingHistory by vm.loadingHistory.collectAsState()
val loadingInitial by vm.initialLoad.collectAsState()
val restoredTimeline by vm.restoredTimeline.collectAsState()
Expand Down Expand Up @@ -232,7 +234,7 @@ fun ChatScreen(vm: AppViewModel) {
val contentShort by remember {
derivedStateOf { !listState.canScrollForward && !listState.canScrollBackward }
}
LaunchedEffect(nearTop, contentShort, hasMoreHistory, loadingHistory, loadingInitial, messages.isEmpty(), searchOlder) {
LaunchedEffect(nearTop, contentShort, hasMoreHistory, loadingHistory, loadingInitial, messages.isEmpty(), searchOlder, pagingNudge) {
// The reversed list is born at index 0 = the NEWEST message, with the
// sentinel a full page away — `nearTop` cannot fire during opening, so
// the chain-load that once parked whole channels at their oldest
Expand Down Expand Up @@ -684,14 +686,27 @@ fun ChatScreen(vm: AppViewModel) {
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)
)
}
if (overridesOwed && visible.isNotEmpty()) {
Text(
if (historyRead == ChannelManager.HistoryRead.FAILED) {
"Edits and deletions could not be loaded. Reopen the channel"
} else {
"Loading edits and deletions…"
},
color = Color.White.copy(alpha = 0.40f), fontSize = 13.sp,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)
)
}

Box(Modifier.weight(1f).fillMaxWidth()) {
// Web renderMessages: while any loading signal is still live the
// area shows a centred spinner; only once everything is quiescent
// and still empty does it say "No messages yet".
if (visible.isEmpty()) {
val terminalEmpty = !loadingInitial && !loadingHistory && !hasMoreHistory &&
historyRead != ChannelManager.HistoryRead.RETRYING
historyRead != ChannelManager.HistoryRead.RETRYING &&
historyRead != ChannelManager.HistoryRead.READING
Column(
// The list below is declared after this and would otherwise
// sit on top, swallowing taps meant for the button here.
Expand Down Expand Up @@ -992,10 +1007,10 @@ fun ChatScreen(vm: AppViewModel) {
fontSize = 14.sp
)
}
// A refused or failed read also clears hasMoreHistory,
// and there the start is unknown, not reached.
// A refused, failed or unfinished read also leaves
// hasMoreHistory false, and there the start is unknown.
!hasMoreHistory && historyError == null && visible.isNotEmpty() &&
historyRead != ChannelManager.HistoryRead.FAILED -> Box(
historyRead == ChannelManager.HistoryRead.OK -> Box(
Modifier.fillMaxWidth().padding(vertical = 16.dp),
contentAlignment = Alignment.Center
) {
Expand Down
Loading
Loading