diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index e3a6ae30b9..1564b18843 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -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: []) } diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index 5f059c7304..7eb2bec75f 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -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 { diff --git a/apps/ios/spec/client/chat-list.md b/apps/ios/spec/client/chat-list.md index d35de1f80a..13a26789c5 100644 --- a/apps/ios/spec/client/chat-list.md +++ b/apps/ios/spec/client/chat-list.md @@ -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 | diff --git a/apps/ios/spec/state.md b/apps/ios/spec/state.md index db16aa2936..07c82e2728 100644 --- a/apps/ios/spec/state.md +++ b/apps/ios/spec/state.md @@ -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) | --- diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 340ba2c1ad..b1344403ab 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -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) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index 486b68ff15..9702b25069 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -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 } diff --git a/apps/multiplatform/product/views/chat-list.md b/apps/multiplatform/product/views/chat-list.md index 3476eadefc..8bffa23865 100644 --- a/apps/multiplatform/product/views/chat-list.md +++ b/apps/multiplatform/product/views/chat-list.md @@ -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 diff --git a/apps/multiplatform/spec/client/chat-list.md b/apps/multiplatform/spec/client/chat-list.md index b0f3750659..92bdd6dd87 100644 --- a/apps/multiplatform/spec/client/chat-list.md +++ b/apps/multiplatform/spec/client/chat-list.md @@ -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 diff --git a/apps/multiplatform/spec/state.md b/apps/multiplatform/spec/state.md index 229c30d18e..d6ca04178a 100644 --- a/apps/multiplatform/spec/state.md +++ b/apps/multiplatform/spec/state.md @@ -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 | diff --git a/plans/2026-08-03-unread-filter-button-highlight.md b/plans/2026-08-03-unread-filter-button-highlight.md index 581c2b247c..b01855c5f3 100644 --- a/plans/2026-08-03-unread-filter-button-highlight.md +++ b/plans/2026-08-03-unread-filter-button-highlight.md @@ -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.