diff --git a/app/src/main/java/chat/matron/android/journal/JournalStore.kt b/app/src/main/java/chat/matron/android/journal/JournalStore.kt index cfd2563..6417f93 100644 --- a/app/src/main/java/chat/matron/android/journal/JournalStore.kt +++ b/app/src/main/java/chat/matron/android/journal/JournalStore.kt @@ -278,9 +278,7 @@ class JournalStore( val newRead = max(convo.readUpToSeq, upTo) convo = convo.copy( readUpToSeq = newRead, - unreadCount = eventDao.countUnread( - convo.id, newRead, JournalEventType.MESSAGE_TYPES, ownSender, - ), + unreadCount = recountUnread(convo.id, newRead), ) } event.type in JournalEventType.MESSAGE_TYPES -> { @@ -301,7 +299,11 @@ class JournalStore( lastMessageType = event.type, expiredSnippet = expiredSnippet(event.type, payload), ) - if (event.sender != ownSender && event.seq > convo.readUpToSeq) { + // A silent event is shown and never counted (protocol "Silent + // events"); [recountUnread] leaves it out too. + if (event.sender != ownSender && event.seq > convo.readUpToSeq && + !JournalEventType.isSilent(event.payload) + ) { convo = convo.copy(unreadCount = convo.unreadCount + 1) } // A message brings an archived conversation back, as the @@ -368,9 +370,7 @@ class JournalStore( val columns = historyColumns(convo) conversationDao.upsert( convo.copy( - unreadCount = eventDao.countUnread( - convoID, convo.readUpToSeq, JournalEventType.MESSAGE_TYPES, ownSender, - ), + unreadCount = recountUnread(convoID, convo.readUpToSeq), lastMessageType = columns.first, expiredSnippet = columns.second, ) @@ -1445,6 +1445,17 @@ class JournalStore( return row.type to expiredSnippet(row.type, parseJsonObjectOrNull(row.payload)) } + /// Unread = message rows from someone else above the read marker, less + /// the silent ones ([JournalEventType.isSilent]). Only rows whose payload + /// mentions the flag are parsed. + private suspend fun recountUnread(convoID: String, afterSeq: Long): Int { + val types = JournalEventType.MESSAGE_TYPES + val total = eventDao.countUnread(convoID, afterSeq, types, ownSender) + val silent = eventDao.unreadMentioningSilent(convoID, afterSeq, types, ownSender) + .count { JournalEventType.isSilent(it.toJournalEvent().payload) } + return max(0, total - silent) + } + /// The TTL columns after a history insert touched [convo]. A row whose /// columns are known re-derives them from the local newest message-type /// event as usual. A row whose columns are UNKNOWN (NULL — a snapshot diff --git a/app/src/main/java/chat/matron/android/journal/WireModels.kt b/app/src/main/java/chat/matron/android/journal/WireModels.kt index ac125c7..770877c 100644 --- a/app/src/main/java/chat/matron/android/journal/WireModels.kt +++ b/app/src/main/java/chat/matron/android/journal/WireModels.kt @@ -126,6 +126,14 @@ object JournalEventType { val MESSAGE_TYPES: Set = setOf( TEXT, TOOL_OUTPUT, DIFF, PROMPT, PERMISSION_REQUEST, FILE, IMAGE, SPAWN_OUTCOME, ) + + /// A silent event is content the user can read in the chat but is never + /// told about: `silent: true` on the payload of any message type + /// (protocol "Silent events"). It is shown like any other row, but never + /// counts as unread. A bridge sets it on a card that only records + /// something, such as the one for a reminder an agent set itself. Only a + /// JSON `true` counts, as on the server. + fun isSilent(payload: JsonObject): Boolean = payload.boolOrNull("silent") == true } /// One durable journal row. `payload` keeps the raw JSON object so arbitrary diff --git a/app/src/main/java/chat/matron/android/journal/db/Daos.kt b/app/src/main/java/chat/matron/android/journal/db/Daos.kt index ee5eb75..92600a2 100644 --- a/app/src/main/java/chat/matron/android/journal/db/Daos.kt +++ b/app/src/main/java/chat/matron/android/journal/db/Daos.kt @@ -260,6 +260,19 @@ interface EventDao { ) suspend fun countUnread(convoID: String, afterSeq: Long, messageTypes: Collection, ownSender: String): Int + /// The rows [countUnread] counts whose payload mentions a `silent` key + /// at all. The store parses these few to take the silent ones off the + /// count (`JournalEventType.isSilent`); SQLite's JSON functions are not + /// on every device. + @Query( + "SELECT * FROM event WHERE convo_id = :convoID AND seq > :afterSeq " + + "AND type IN (:messageTypes) AND sender != :ownSender " + + "AND instr(payload, '\"silent\"') > 0" + ) + suspend fun unreadMentioningSilent( + convoID: String, afterSeq: Long, messageTypes: Collection, ownSender: String, + ): List + /// One page of a maintenance sweep: rows of the given [types] with /// `ts <= cutoff`, walking the `event_type_ts` index forward from the /// keyset `(afterTS, afterSeq)`. Keyset rather than OFFSET paging: rows diff --git a/app/src/test/java/chat/matron/android/journal/JournalStoreTest.kt b/app/src/test/java/chat/matron/android/journal/JournalStoreTest.kt index 94d90ad..6df7709 100644 --- a/app/src/test/java/chat/matron/android/journal/JournalStoreTest.kt +++ b/app/src/test/java/chat/matron/android/journal/JournalStoreTest.kt @@ -290,6 +290,32 @@ class JournalStoreTest { assertEquals(1, convo.unreadCount) } + @Test + fun aSilentEventIsStoredButNeverCountsAsUnread() = runBlocking { + val store = makeStore() + val card = buildJsonObject { + put("question", "Reminder set"); put("blocking", false); put("silent", true) + } + store.applyJournal(ev(1)) + store.applyJournal(ev(2, type = "prompt", payload = card)) + store.applyJournal(ev(3, payload = buildJsonObject { put("body", "done"); put("silent", true) })) + // Only a JSON true is silent, and a body that merely mentions the + // word is an ordinary message. + store.applyJournal(ev(4, payload = buildJsonObject { put("body", "x"); put("silent", 1) })) + store.applyJournal(ev(5, payload = body("the \"silent\" flag"))) + assertEquals(3, store.conversations().first().unreadCount) + // Still content: it is the row's latest line. + store.applyJournal(ev(6, type = "prompt", payload = card)) + assertEquals(3, store.conversations().first().unreadCount) + assertEquals("? Reminder set", store.conversations().first().snippet) + // A read marker recounts without them as well. + store.applyJournal( + ev(7, sender = "user:alice", type = "read_marker", + payload = buildJsonObject { put("convo_id", "c1"); put("up_to_seq", 1) }) + ) + assertEquals(2, store.conversations().first().unreadCount) + } + @Test fun sessionStatusUpdatesStateWithoutUnread() = runBlocking { val store = makeStore()