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
25 changes: 18 additions & 7 deletions app/src/main/java/chat/matron/android/journal/JournalStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 -> {
Expand All @@ -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
Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions app/src/main/java/chat/matron/android/journal/WireModels.kt
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,14 @@ object JournalEventType {
val MESSAGE_TYPES: Set<String> = 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
Expand Down
13 changes: 13 additions & 0 deletions app/src/main/java/chat/matron/android/journal/db/Daos.kt
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,19 @@ interface EventDao {
)
suspend fun countUnread(convoID: String, afterSeq: Long, messageTypes: Collection<String>, 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<String>, ownSender: String,
): List<EventEntity>

/// 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
Expand Down
26 changes: 26 additions & 0 deletions app/src/test/java/chat/matron/android/journal/JournalStoreTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading