ui: highlight the filter button from the current profile's unread count

The tint scanned chats for the same predicate the filter matches on. Read the
active profile's UserInfo.unreadCount instead - the number the profile picker
already shows against that profile - so the button reports unread the way the
rest of the app counts it. Scoped to the active user, so other profiles, hidden
ones especially, never light a control on the main screen; that is the toolbar
dot's job, and it excludes the active user for the same reason.

The tint and the filter are now two rules and can disagree, which is accepted:
getUsersInfo counts chat_items, so it does not see a chat marked unread by hand,
it drops muted chats (and under mentions-only counts a support item only if it
mentions the user), and it counts messages rather than pending members. The
button is a cue that something is worth a tap; the filter stays the authority on
what is listed.

Drops the !chatDeleted && !contactCard guard from the button along with the
scan - counting items, it cannot see a cleared-then-deleted chat. The plan doc
gains a correction: that guard was attributed to ChatDeleteMode.Entity, which
never sets chat_deleted; only ChatDeleteMode.Messages does, and the state it
leaves behind needs a restart and a surviving unread_chat flag to appear.
This commit is contained in:
Narasimha-sc
2026-08-28 09:11:53 +00:00
parent ea79bde724
commit ed5c861886
4 changed files with 30 additions and 20 deletions
@@ -764,14 +764,14 @@ struct ChatListSearchBar: View {
private func toggleFilterButton() -> some View {
let showUnread = chatTagsModel.activeFilter == .unread
let anyUnread = m.chats.contains { !$0.chatInfo.chatDeleted && !$0.chatInfo.contactCard && $0.hasUnread }
let currentUserUnread = m.users.contains { $0.user.activeUser && $0.unreadCount > 0 }
return ZStack {
Color.clear
.frame(width: 22, height: 22)
Image(systemName: showUnread ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease")
.resizable()
.scaledToFit()
.foregroundColor(showUnread || anyUnread ? theme.colors.primary : theme.colors.secondary)
.foregroundColor(showUnread || currentUserUnread ? theme.colors.primary : theme.colors.secondary)
.frame(width: showUnread ? 22 : 16, height: showUnread ? 22 : 16)
.onTapGesture {
if chatTagsModel.activeFilter == .unread {
@@ -707,7 +707,7 @@ private fun BoxScope.unreadBadge(text: String? = "") {
@Composable
private fun ToggleFilterEnabledButton() {
val showUnread = remember { chatModel.activeChatTagFilter }.value == ActiveFilter.Unread
val anyUnread = chatModel.chats.value.any { !it.chatInfo.chatDeleted && !it.chatInfo.contactCard && it.hasUnread }
val currentUserUnread = chatModel.users.any { it.user.activeUser && it.unreadCount > 0 }
IconButton(onClick = {
if (showUnread) {
@@ -720,7 +720,7 @@ private fun ToggleFilterEnabledButton() {
Icon(
painterResource(MR.images.ic_filter_list),
null,
tint = if (showUnread) MaterialTheme.colors.background else if (anyUnread) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
tint = if (showUnread) MaterialTheme.colors.background else if (currentUserUnread) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
modifier = Modifier
.padding(3.dp)
.background(color = if (showUnread) MaterialTheme.colors.primary else Color.Unspecified, shape = RoundedCornerShape(50))
@@ -44,7 +44,7 @@ The toolbar supports two layout modes controlled by `appPrefs.oneHandUI`:
|---|---|
| Search icon | Magnifying glass icon at leading edge |
| Text field | `SearchTextField` with placeholder "Search or paste SimpleX link" |
| Filter button | `ToggleFilterEnabledButton` (filter icon) toggles unread-only filter, highlighted while any chat matches it; shown when search text is empty |
| Filter button | `ToggleFilterEnabledButton` (filter icon) toggles unread-only filter, highlighted while the current profile has unread; shown when search text is empty |
| Clear button | Appears when text is entered; `BackHandler` clears search on back |
Behavior:
@@ -10,17 +10,17 @@ The filter was also missing a whole category of unread. A group where a member i
## Fix
Include unread support chats in what the unread filter matches, and tint the filter icon with the accent colour while it matches anything. Same icon, same size — only the colour changes, so it stays visually distinct from the filter's active state (a filled accent pill on Android/desktop, a filled circle glyph at 22pt on iOS).
Two changes. Include unread support chats in what the unread filter matches. And tint the filter icon with the accent colour while the current profile has unread, reading the same count the profile picker shows. Same icon, same size — only the colour changes, so it stays visually distinct from the filter's active state (a filled accent pill on Android/desktop, a filled circle glyph at 22pt on iOS).
| Filter | Matching chats | Android / desktop | iOS |
| Filter | This profile's unread count | Android / desktop | iOS |
|---|---|---|---|
| off | none | grey lines | grey lines |
| off | some | **accent lines** | **accent lines** |
| off | 0 | grey lines | grey lines |
| off | > 0 | **accent lines** | **accent lines** |
| on | — | background-coloured lines on accent pill | accent filled circle |
## Why `Chat.hasUnread`
## What the filter matches: `Chat.hasUnread`
The highlight uses `hasUnread` — the same property `ActiveFilter.Unread` matches on in `filtered()` (`ChatListView.kt`) and `filtered(_:)` (`ChatListView.swift`). So the button is highlighted exactly when the filter has something to show, and the unread test cannot drift: it is the same symbol, not two copies of one rule.
`ActiveFilter.Unread` matches `hasUnread` in `filtered()` (`ChatListView.kt`) and `filtered(_:)` (`ChatListView.swift`).
`hasUnread` is `unreadTag || supportUnreadCount > 0`. The `unreadTag` half carries the mute semantics — a muted chat counts only when manually marked unread, and a mentions-only chat only on unread mentions. The `supportUnreadCount` half is what the chat list row already flags (`ChatPreviewView`): for a moderator the group's `membersRequireAttention`, otherwise the member's own `supportChat.unread`.
@@ -30,22 +30,32 @@ Two consequences of taking `supportUnreadCount` whole are deliberate.
*Mute does not suppress the support half.* `unreadTag` gates on `enableNtfs`; `supportUnreadCount` never reads `chatSettings`, and this predicate does not add that gate. Muting a group silences its conversation but not its support chats — a message to or from admins still lights the button. That is intentional: mute is about group chatter, not about the user's own line to the people running the group.
Support chats belong in this predicate because `unreadTag` alone cannot see them. `chatStats.unreadCount` is filled by a query scoped to the main conversation (`group_scope_tag IS NULL AND group_scope_group_member_id IS NULL`, `Store/Messages.hs`), so a group whose only unread sits in a member support scope satisfies neither `unreadTag` nor the filter — while the profile picker's badge, whose count comes from `getUsersInfo` (`Store/Profiles.hs`) with no scope filter at all, does include it. Before this the two disagreed: the picker showed a count, the filter button stayed grey and the filter listed nothing, and there was no way to reach the waiting member from the chat list. Matching the picker's notion of unread is the point; sharing one per-chat symbol between the tint and the filter is how it is done without the button being able to lie.
Reading `users[].unreadCount` directly was rejected. It is a per-*user* aggregate, so it counts other profiles — including hidden ones, which the toolbar dot deliberately excludes (`!u.user.hidden`) and which must not be signalled on the main screen. It also counts chats the list does not show, reintroducing the `chatDeleted` bug below, and being a count rather than a per-chat predicate it cannot be the symbol the filter matches on, which is the property this whole design rests on.
Support chats belong in this predicate because `unreadTag` alone cannot see them. `chatStats.unreadCount` is filled by a query scoped to the main conversation (`group_scope_tag IS NULL AND group_scope_group_member_id IS NULL`, `Store/Messages.hs`), so a group whose only unread sits in a member support scope satisfies neither `unreadTag` nor the filter. There was no way to reach the waiting member from the chat list.
`unreadTag` itself is deliberately left alone: it also drives the user-tag `●` badges, whose counters are maintained incrementally (`updateChatTagReadInPrimaryContext`), and support-unread changes arrive through `updateChatInfo`, which replaces `chatInfo` without running that bookkeeping. Folding support chats into `unreadTag` would desync the tag counts.
The predicate also repeats the list-visibility guard `!chatDeleted && !contactCard` that `filteredChats` applies before any filter. Without it the button lies: deleting a contact with "keep conversation" (`ChatDeleteMode.Entity`) routes to `updateContact``updateChatInfo`, which replaces `chatInfo` but preserves `chatStats` — so the chat keeps its unread count, becomes `chatDeleted`, and drops out of the list while still satisfying `unreadTag`. The button would stay highlighted with the filter permanently empty, and nothing would clear it because the chat is no longer reachable from the list. This guard is the one rule here that is copied rather than shared — if `filteredChats` ever hides chats on a further condition, this predicate needs the same term.
## What the button reads: the current profile's unread count
The tint is `users.any { it.user.activeUser && it.unreadCount > 0 }` — the same number the profile picker shows against this profile, and nothing else. It is computed server-side by `getUsersInfo` (`Store/Profiles.hs`), which counts `chat_items` with `item_status = CISRcvNew`, honours `enable_ntfs` on both contacts and groups, and applies no group-scope filter, so support-scope items are already included. Scoping to the active user keeps other profiles — hidden ones especially — out of a signal on the main screen; that is the toolbar dot's job, and it excludes the active user for the same reason.
This is deliberately *not* the predicate the filter matches on, so the two can disagree. Where they do:
- **Marked unread.** `unread_chat` is a column on `contacts`/`groups`, and `getUsersInfo` never reads it. A chat you marked unread by hand lists under the filter with the button grey.
- **Muted chats.** The counter's group clause is `enable_ntfs = 1 OR IS NULL OR (enable_ntfs = 2 AND user_mention = 1)` (`MFNone = 0`, `MFAll = 1`, `MFMentions = 2`, `Types.hs`). So under *mute all* no support item counts, and under *mentions only* a support item counts only if it mentions the user — an ordinary support message does not. Either way the group lists under the filter, which ignores mute for the support half, without lighting the button. The contact clause is stricter still: `enable_ntfs = 1 OR IS NULL`, with no mentions term.
- **Pending members.** The counter counts messages, so a member awaiting approval with nothing written lists under the filter without lighting the button. The stickiness described above therefore never reaches the tint.
Accepted: the button is a cue that something is worth a tap, matching the count the user already recognises from the picker, and the filter is the authority on what is actually listed.
One thing this removes. The tint no longer scans chats, so the `!chatDeleted && !contactCard` guard it used to repeat from `filteredChats` is gone, and the counter does not need it. Worth correcting the record on why that guard was added: it was attributed to `ChatDeleteMode.Entity`, but that mode sets contact status `CSDeletedByUser` and never touches `chat_deleted` (`Library/Commands.hs`), so its chat stays in the list. `chat_deleted` is set only by `ChatDeleteMode.Messages` ("Only delete conversation"), which runs `APIClearChat` first — and that deletes the items but never resets `contacts.unread_chat` (only `setContactChatUnread` and the bulk mark-all-read do, `Store/Direct.hs`). So a chat marked unread before "Only delete conversation" returns after a restart with `unreadChat` true and `chatDeleted` true: hidden from the list, still matching `hasUnread`. That is a real state, it still needs the guard inside `filteredChats`, and the button — counting items, of which there are none — is immune to it.
## Reactivity
Both platforms already depend on exactly this signal to render the filtered list itself, so no new machinery is needed for the `unreadTag` half:
The tint reads `users`, which both platforms already publish on:
- Android/desktop — every unread change replaces the chat in the `SnapshotStateList` (`chats[i] = chat.copy(chatStats = …)`), so reading the list recomposes the button. `updateChatInfo` replaces the element the same way, so the `supportUnreadCount` half recomposes too.
- iOS — `_updateChat` assigns back into `@Published chats`, and the debounced counter path mutates `@Published users` (a struct array); either fires `ChatModel.objectWillChange`. Note the `UnreadCollector` 1s debounce means the highlight clears a beat after reading a chat, as the filtered list already does.
- Android/desktop — `users` is a `mutableStateListOf<UserInfo>`, and `changeUnreadCounterInPrimaryContext` replaces the element (`users[i] = users[i].copy(unreadCount = …)`), so reading it recomposes the button.
- iOS — `users` is `@Published var users: [UserInfo]`, an array of structs, so `users[i].unreadCount += by` fires `ChatModel.objectWillChange`. Note `changeUnreadCounter(user:by:)` returns early when `by == 0`, which is correct here: no delta, no change to display. The `UnreadCollector` 1s debounce means the tint clears a beat after reading a chat, as the filtered list already does.
Known gap, iOS only. `supportUnreadCount` reads `chatInfo`, and `updateChatInfo` writes `chats[i].chatInfo = cInfo` — a property write through the `Chat` reference, not an assignment into the array — so it fires `Chat.objectWillChange` but not `ChatModel.objectWillChange`. `ChatListSearchBar` observes the model, not the individual chats, so it does not see that write. For a support-scope item `addChatItem` also skips the `chats[i].chatItems` branch and the unread collector (both are gated on `groupChatScope() == nil`), leaving `throttlePopChat` as the only path back to a model publish and that enqueues nothing when the group is already at position 0. So when a support message arrives in a group that is already at the top of the list, the tint and the filtered list stay stale until the next unrelated publish (any other chat activity, or re-entering the list, which re-evaluates `filteredChats()`). The chat row's own flag icon is unaffected, since `ChatPreviewView` observes the `Chat`. Android/desktop have no such gap. The one-line fix is to write back into the array in `updateChatInfo` (`let chat = chats[i]; …; chats[i] = chat`, as `_updateChat` does), but that publishes the whole list on every `chatInfo` update and so belongs in its own change, measured.
Taking the tint off the chat scan also takes it out of the way of an iOS gap that remains for the *filter*. `supportUnreadCount` reads `chatInfo`, and `updateChatInfo` writes `chats[i].chatInfo = cInfo` — a property write through the `Chat` reference, not an assignment into the array — so it fires `Chat.objectWillChange` but not `ChatModel.objectWillChange`. For a support-scope item `addChatItem` also skips the `chats[i].chatItems` branch and the unread collector (both gated on `groupChatScope() == nil`), leaving `throttlePopChat` as the only route to a model publish, and that enqueues nothing when the group is already at position 0. So a support message arriving in a group already at the top of the list may not refresh filter membership until the next unrelated publish, or until re-entering the list, which re-evaluates `filteredChats()`. The row's own flag icon is unaffected, since `ChatPreviewView` observes the `Chat`. Android/desktop have no such gap. The one-line fix is to write back into the array in `updateChatInfo` (`let chat = chats[i]; …; chats[i] = chat`, as `_updateChat` does), but that publishes the whole list on every `chatInfo` update and so belongs in its own change, measured.
## Scope
@@ -53,6 +63,6 @@ Not touched:
- The contacts-list filter button (`NewChatSheet.kt`, `NewChatMenuButton.swift`) — despite the `showUnreadAndFavorites` name it filters favourites, not unread, so an unread highlight there would be wrong.
- The `ic_filter_list` icon in the "No unread chats" empty state — it only renders while the filter is on and empty, so it correctly stays grey.
- `Chat.unreadTag`, and with it the user-tag `●` badges and the preset tag counts — see above.
- The profile picker badge and the toolbar dot. They answer a different question ("another profile has unread", active user excluded) and keep their own count.
- The profile picker badge and the toolbar dot. The tint now reads the same per-profile count the picker renders, but neither of those surfaces changes: the picker still shows a number per profile, and the dot still answers "another profile has unread", active user excluded.
The highlight is additive: every pre-existing colour state is unchanged and exactly one new state is introduced. The filter is not — a group whose only unread is in a support chat now appears in the filtered list where it previously did not, which is the point.