Unread badges survive a restart
The unread markers were a Set in the view model's UI state and nowhere else. Kill the app with unread conversations and every badge was gone at the next launch, whether or not anything had been read. The same fault as the story reactions were: a mark somebody has not yet dealt with is theirs until they deal with it, not until the process ends. The vault keeps the set now. It is seeded from there when the signed-in state is built, and written from `update()`, the one chokepoint every state change passes through, whenever the set differs from before. That is one write for the four sites that mutate it today and for any fifth one, rather than four writes that a fifth could forget. Synchronous on purpose: it is a short list of ids, and dispatching it would let two quick updates land out of order. Coverage, honestly: the vault round trip is tested in both directions, including that a cleared set stays cleared. The view model chokepoint is covered by compile and the full suite; there is no view model harness beyond a screenshot fixture, so the seed and the write are verified by reading rather than by a failing test. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
ae618cd1f3
commit
4235754d0c
@@ -344,6 +344,11 @@ class MessengerViewModel(
|
||||
status = "Signed in on this device.",
|
||||
buddies = fetchedBuddies.getOrDefault(emptyList()),
|
||||
buddiesLoaded = fetchedBuddies.isSuccess,
|
||||
// From the vault, or every badge is gone at
|
||||
// each launch whether or not the messages
|
||||
// were read. The markers were UI state
|
||||
// alone until now.
|
||||
unread = session?.vault?.unreadThreads() ?: emptySet(),
|
||||
avatars =
|
||||
session?.avatars(
|
||||
fetchedBuddies.getOrDefault(emptyList()).map { b -> b.userId },
|
||||
@@ -649,7 +654,19 @@ class MessengerViewModel(
|
||||
private fun signedIn(): Phase.SignedIn? = _phase.value as? Phase.SignedIn
|
||||
|
||||
private fun update(block: (Phase.SignedIn) -> Phase.SignedIn) {
|
||||
signedIn()?.let { _phase.value = block(it) }
|
||||
signedIn()?.let { before ->
|
||||
val after = block(before)
|
||||
_phase.value = after
|
||||
// The one chokepoint every unread change passes through, so it is
|
||||
// persisted here rather than at the four sites that mutate it,
|
||||
// and a fifth site cannot forget. Only on change: this runs for
|
||||
// every state update, and most touch nothing unread. Synchronous
|
||||
// on purpose; it is a short list of ids, and dispatching it would
|
||||
// let two quick updates land out of order.
|
||||
if (after.unread != before.unread) {
|
||||
runCatching { session?.vault?.saveUnreadThreads(after.unread) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshBuddies() =
|
||||
|
||||
@@ -75,6 +75,7 @@ class Vault(
|
||||
private const val STORY_ARCHIVE = "story-archive"
|
||||
private const val STORY_REACTIONS = "story-reactions"
|
||||
private const val PINNED_PREFIX = "pinned-"
|
||||
private const val UNREAD_THREADS = "unread-threads"
|
||||
private const val DISAPPEAR = "disappear"
|
||||
private const val OFF_RECORD = "offrecord"
|
||||
private const val POLLS = "polls"
|
||||
@@ -1222,6 +1223,26 @@ class Vault(
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Which threads have something unread, by thread id.
|
||||
*
|
||||
* The unread markers lived in the view model's UI state and nowhere else,
|
||||
* so killing the app with unread conversations cleared every one of them.
|
||||
* Kept here for the same reason the story mutes are: a mark somebody has
|
||||
* not yet dealt with is theirs until they deal with it, not until the
|
||||
* process ends.
|
||||
*/
|
||||
fun unreadThreads(): Set<String> {
|
||||
val array = readJson(UNREAD_THREADS)?.optJSONArray("ids") ?: return emptySet()
|
||||
return (0 until array.length()).map { array.optString(it) }.filter { it.isNotEmpty() }.toSet()
|
||||
}
|
||||
|
||||
fun saveUnreadThreads(ids: Set<String>) {
|
||||
val array = org.json.JSONArray()
|
||||
ids.sorted().forEach { array.put(it) }
|
||||
writeJson(UNREAD_THREADS, JSONObject().put("ids", array))
|
||||
}
|
||||
|
||||
fun saveStoryReactions(reactions: Map<String, Map<Long, String>>) {
|
||||
val root = JSONObject()
|
||||
for ((storyKey, byUser) in reactions) {
|
||||
|
||||
@@ -83,6 +83,36 @@ class VaultPersistenceTest {
|
||||
assertTrue(restored.isEmpty(), "an empty tally came back as $restored")
|
||||
}
|
||||
|
||||
// -- Unread threads ------------------------------------------------------.
|
||||
|
||||
/**
|
||||
* These lived in the view model's UI state and nowhere else, so killing
|
||||
* the app with unread conversations cleared every badge.
|
||||
*/
|
||||
@Test
|
||||
fun `unread threads survive a restart`() {
|
||||
val restored =
|
||||
roundTrip(
|
||||
{ it.saveUnreadThreads(setOf("u:7", "group:gid-1", "addr:ada@relay.example")) },
|
||||
{ it.unreadThreads() },
|
||||
)
|
||||
assertEquals(setOf("u:7", "group:gid-1", "addr:ada@relay.example"), restored)
|
||||
}
|
||||
|
||||
/** Reading a thread has to survive the restart as well, or the badge comes back. */
|
||||
@Test
|
||||
fun `a cleared unread set stays cleared`() {
|
||||
val restored =
|
||||
roundTrip(
|
||||
{
|
||||
it.saveUnreadThreads(setOf("u:7"))
|
||||
it.saveUnreadThreads(emptySet())
|
||||
},
|
||||
{ it.unreadThreads() },
|
||||
)
|
||||
assertTrue(restored.isEmpty(), "a badge came back after being cleared: $restored")
|
||||
}
|
||||
|
||||
// -- Credential ----------------------------------------------------------.
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user