ui: include unread support chats in the chat list unread filter

A group where a member is waiting in a support chat has something to read - the
chat list row already flags it - but the unread filter did not list it and the
filter button stayed grey. chatStats.unreadCount is filled by a query scoped to
the main conversation (group_scope_tag IS NULL), so unreadTag cannot see support
scopes, while the profile picker's badge, counted by getUsersInfo with no scope
filter, does include them. The two disagreed, and there was no way to reach the
waiting member from the chat list.

Match on Chat.hasUnread = unreadTag || supportUnreadCount > 0. The button's tint
reads the same property the filter matches on, so both gained support chats in
one change and the button still cannot claim chats the filter would not show.

Two consequences of taking supportUnreadCount whole are deliberate, and are
written up in the plan: members pending approval count towards it and only
approving or rejecting clears them, so the signal is sticky for a moderator with
a join request waiting; and the support half does not respect mute, on the view
that muting group chatter should not mute the user's line to admins.

unreadTag is left alone: it also drives the user-tag badges, whose counters are
maintained incrementally, and support-unread changes arrive via updateChatInfo,
which does not run that bookkeeping.
This commit is contained in:
Narasimha-sc
2026-08-28 08:23:56 +00:00
parent 52c7f225ab
commit ea79bde724
10 changed files with 43 additions and 19 deletions
+2
View File
@@ -1407,6 +1407,8 @@ final class Chat: ObservableObject, Identifiable, ChatLike {
}
}
var hasUnread: Bool { unreadTag || supportUnreadCount > 0 }
public static var sampleData: Chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])
}
@@ -552,7 +552,7 @@ struct ChatListView: View {
switch chatTagsModel.activeFilter {
case let .presetTag(tag): presetTagMatchesChat(tag, chat.chatInfo, chat.chatStats)
case let .userTag(tag): chat.chatInfo.chatTags?.contains(tag.chatTagId) == true
case .unread: chat.unreadTag
case .unread: chat.hasUnread
case .none: true
}
}
@@ -764,14 +764,14 @@ struct ChatListSearchBar: View {
private func toggleFilterButton() -> some View {
let showUnread = chatTagsModel.activeFilter == .unread
let hasUnread = m.chats.contains { !$0.chatInfo.chatDeleted && !$0.chatInfo.contactCard && $0.unreadTag }
let anyUnread = m.chats.contains { !$0.chatInfo.chatDeleted && !$0.chatInfo.contactCard && $0.hasUnread }
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 || hasUnread ? theme.colors.primary : theme.colors.secondary)
.foregroundColor(showUnread || anyUnread ? theme.colors.primary : theme.colors.secondary)
.frame(width: showUnread ? 22 : 16, height: showUnread ? 22 : 16)
.onTapGesture {
if chatTagsModel.activeFilter == .unread {
+1 -1
View File
@@ -163,7 +163,7 @@ Horizontal scrolling tab bar below the navigation bar. Tabs:
| Tab | Filter | Shows |
|-----|--------|-------|
| All | `nil` | All conversations |
| Unread | `.unread` | Conversations with unread messages |
| Unread | `.unread` | Conversations with unread messages or unread support chats |
| Favorites | `.presetTag(.favorites)` | Favorited conversations |
| Groups | `.presetTag(.groups)` | Group conversations |
| Contacts | `.presetTag(.contacts)` | Direct conversations |
+1
View File
@@ -330,6 +330,7 @@ struct ChatStats: Decodable, Hashable {
| `viewId` | Unique view identity including creation time | [L1338](../Shared/Model/ChatModel.swift#L1338) |
| `unreadTag` | Whether chat counts as "unread" based on notification settings | [L1328](../Shared/Model/ChatModel.swift#L1328) |
| `supportUnreadCount` | Unread count for group support scope | [L1340](../Shared/Model/ChatModel.swift#L1340) |
| `hasUnread` | Whether chat matches the unread filter: `unreadTag` or any support unread | [L1410](../Shared/Model/ChatModel.swift#L1410) |
---
@@ -1437,6 +1437,8 @@ data class Chat(
else -> 0
}
val hasUnread: Boolean get() = unreadTag || supportUnreadCount > 0
fun groupFeatureEnabled(feature: GroupFeature): Boolean =
if (chatInfo is ChatInfo.Group) {
chatInfo.groupInfo.groupFeatureEnabled(feature)
@@ -707,7 +707,7 @@ private fun BoxScope.unreadBadge(text: String? = "") {
@Composable
private fun ToggleFilterEnabledButton() {
val showUnread = remember { chatModel.activeChatTagFilter }.value == ActiveFilter.Unread
val hasUnread = chatModel.chats.value.any { !it.chatInfo.chatDeleted && !it.chatInfo.contactCard && it.unreadTag }
val anyUnread = chatModel.chats.value.any { !it.chatInfo.chatDeleted && !it.chatInfo.contactCard && it.hasUnread }
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 (hasUnread) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
tint = if (showUnread) MaterialTheme.colors.background else if (anyUnread) 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))
@@ -1475,7 +1475,7 @@ private fun filtered(chat: Chat, activeFilter: ActiveFilter?): Boolean =
when (activeFilter) {
is ActiveFilter.PresetTag -> presetTagMatchesChat(activeFilter.tag, chat.chatInfo, chat.chatStats)
is ActiveFilter.UserTag -> chat.chatInfo.chatTags?.contains(activeFilter.tag.chatTagId) ?: false
is ActiveFilter.Unread -> chat.unreadTag
is ActiveFilter.Unread -> chat.hasUnread
else -> true
}
@@ -65,7 +65,7 @@ Managed by `chatModel.userTags`, `chatModel.presetTags`, and `chatModel.activeCh
| Business | `BUSINESS` | Work | Business chat conversations |
| Notes | `NOTES` | Folder | Notes to self |
| Custom tags | `UserTag(ChatTag)` | Label/emoji | User-created tags with custom emoji and name |
| Unread | `ActiveFilter.Unread` | Filter list icon | Chats with unread messages (toggle via filter button) |
| Unread | `ActiveFilter.Unread` | Filter list icon | Chats with unread messages or unread support chats (toggle via filter button) |
Display logic:
- When collapsible preset tags exceed 3 total (with user tags), they collapse into a `CollapsedTagsFilterView` dropdown menu
+1 -1
View File
@@ -143,7 +143,7 @@ The `filteredChats` function (line ~1188) applies filters in this order:
3. **Active filter:**
- `PresetTag`: Matches chat type and characteristics (e.g., `CONTACTS` filters `ChatInfo.Direct`, `GROUPS` filters `ChatInfo.Group`).
- `UserTag`: Matches chats whose `chatTags` contain the tag ID.
- `Unread`: Matches chats with `unreadCount > 0` or `unreadChat == true`.
- `Unread`: Matches `Chat.hasUnread` — unread messages, as the chat's notification setting counts them, or unread support chats.
### Search Bar
+1
View File
@@ -282,6 +282,7 @@ data class ChatStats(
| `id` | 1349 | Chat ID derived from `chatInfo.id` |
| `unreadTag` | 1343 | Whether chat counts as "unread" for tag filtering (considers notification settings) |
| `supportUnreadCount` | 1351 | Unread count in support/moderation context |
| `hasUnread` | 1440 | Whether chat matches the unread filter: `unreadTag` or any support unread |
| `nextSendGrpInv` | 1337 | Whether next message should send group invitation |
<a id="ChatInfo"></a>
@@ -1,4 +1,4 @@
# Highlight the chat list filter button while any chat is unread
# Highlight the chat list filter button, and count support chats as unread
## Problem
@@ -6,35 +6,53 @@ The unread filter button in the chat list search bar looks the same whether or n
User tags already solve this for themselves: a tag chip carries a `●` badge when its chats have unread (`TagsView` in `ChatListView.kt`, driven by `unreadTags`). The unread filter button had no equivalent.
The filter was also missing a whole category of unread. A group where a member is waiting in a support chat has something to read — the chat list row already says so with an accent flag — but the filter did not list it, because the unread test only ever looked at the main conversation. A moderator filtering for unread would silently skip the member waiting on them.
## Fix
Tint the filter icon with the accent colour while any chat is unread. 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).
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).
| Filter | Unread chats | Android / desktop | iOS |
| Filter | Matching chats | Android / desktop | iOS |
|---|---|---|---|
| off | none | grey lines | grey lines |
| off | some | **accent lines** | **accent lines** |
| on | — | background-coloured lines on accent pill | accent filled circle |
## Why `Chat.unreadTag`
## Why `Chat.hasUnread`
The highlight uses `unreadTag` — 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. It also inherits the right mute semantics for free — a muted chat counts only when manually marked unread, and a mentions-only chat only on unread mentions.
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.
`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`.
Two consequences of taking `supportUnreadCount` whole are deliberate.
*Pending members count, and that signal is sticky.* For a moderator `supportUnreadCount` is `membersRequireAttention`, and the core computes `gmRequiresAttention` as `memberPending m || memberAttention > 0 || mentions > 0` (`Types.hs`) — so a member awaiting approval or review counts even with nothing to read. `updateSupportChatItemsRead` (`Store/Messages.hs`) zeroes the support counters but decrements the group's count only when the member stops requiring attention, which a pending member never does; approving or rejecting is what clears it. A moderator with a pending join request therefore sees the button tinted and the group listed under Unread with no unread badge until they act on the request. That is accepted: the member is genuinely waiting on the moderator, and the filter is where they will look for who is waiting. It does disagree with the chat list row, which renders `memberPending` as a grey flag rather than a call to action.
*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.
`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.
The alternative, `users[].unreadCount`, was rejected: `changeUnreadCounter(user:by:)` increments it unconditionally, so muted chats would light the button while the filter showed nothing.
## Reactivity
Both platforms already depend on exactly this signal to render the filtered list itself, so no new machinery is needed:
Both platforms already depend on exactly this signal to render the filtered list itself, so no new machinery is needed for the `unreadTag` half:
- Android/desktop — every unread change replaces the chat in the `SnapshotStateList` (`chats[i] = chat.copy(chatStats = …)`), so reading the list recomposes the button.
- 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.
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.
## Scope
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 change is additive: every pre-existing state renders the identical colour, and exactly one new state is introduced.
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.