mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-08-23 01:19:51 +00:00
plans: desktop taskbar unread count
Product spec and implementation plan for showing per-window-title unread count "SimpleX [N]" on the desktop app. Active profile contributes a per-chat-mute-filtered sum (All / Mentions / None per chatStats), other profiles contribute their UserInfo.unreadCount when not hidden and not profile-level-muted. The two surfaces (this taskbar count and the existing tray-tooltip count) agree in steady state; the active-profile path differs in transient states because the in-memory increment for the active user does not apply the per-chat mute filter at the call site.
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
# Desktop taskbar unread count — implementation plan
|
||||
|
||||
Companion to `2026-05-18-desktop-taskbar-unread-count-spec.md`. The spec defines what the user sees and why. This plan defines what the diff looks like.
|
||||
|
||||
## Surface
|
||||
|
||||
`DesktopApp.kt:124-130`:
|
||||
|
||||
```kotlin
|
||||
Window(state = windowState,
|
||||
visible = simplexWindowState.windowVisible.value,
|
||||
icon = painterResource(MR.images.ic_simplex),
|
||||
onCloseRequest = { handleCloseRequest(closedByError) },
|
||||
onKeyEvent = { ... },
|
||||
title = "SimpleX") {
|
||||
```
|
||||
|
||||
Compose Multiplatform's `androidx.compose.ui.window.Window` is a composable. Its `title: String` parameter, when it changes between recompositions, propagates to the backing `ComposeWindow.title`, which is an AWT `Frame.title` — and `Frame.setTitle` is the canonical mechanism every JVM uses to update the OS taskbar / window-list entry on Linux, Windows, and macOS. No new platform glue, no JNI, no native bridge.
|
||||
|
||||
The change is local to that one parameter. Everything else in `AppWindow` stays.
|
||||
|
||||
## Counting logic — one composable, two paths
|
||||
|
||||
In `AppWindow` (`DesktopApp.kt`), introduce a derived integer that re-evaluates whenever the inputs change, then format the title from it:
|
||||
|
||||
```kotlin
|
||||
val unreadTotal by remember {
|
||||
derivedStateOf {
|
||||
// Active profile — apply per-chat mute filter exactly, the same rule
|
||||
// that drives Chat.unreadTag (ChatModel.kt:1383-1387).
|
||||
val active = ChatModel.chats.value.sumOf { c ->
|
||||
when (c.chatInfo.chatSettings?.enableNtfs) {
|
||||
MsgFilter.All -> c.chatStats.unreadCount
|
||||
MsgFilter.Mentions -> c.chatStats.unreadMentions
|
||||
else -> 0 // MsgFilter.None or null (no settings — treated as muted)
|
||||
}
|
||||
}
|
||||
// Other profiles — non-active, not hidden, not profile-level-muted.
|
||||
// Same non-active-profile filter the tray uses (DesktopTray.kt:62-68) plus
|
||||
// a hidden-profile skip to match the profile picker (UserPicker.kt:60).
|
||||
val others = ChatModel.users.sumOf { u ->
|
||||
if (!u.user.activeUser && !u.user.hidden && u.user.showNtfs) u.unreadCount else 0
|
||||
}
|
||||
active + others
|
||||
}
|
||||
}
|
||||
val title = if (unreadTotal > 0) "SimpleX [$unreadTotal]" else "SimpleX"
|
||||
```
|
||||
|
||||
Then pass `title = title` into `Window(...)`. The bracketed format is hard-coded — no string resource, because the literal characters `[` `]` and ASCII digits do not localize. The plain "SimpleX" branch matches the existing hard-coded literal.
|
||||
|
||||
### Why the asymmetry between active and others
|
||||
|
||||
`ChatModel.chats` proxies `chatsContext.chats`, which always holds whichever profile is currently active — non-active profiles do not have their chats loaded into the desktop client. For them we use `UserInfo.unreadCount`, which is mute-filtered at every mutation point: the SQL in `getUserInfo` (`src/Simplex/Chat/Store/Profiles.hs:175-196`) filters by `enable_ntfs` on load, and the Kotlin event dispatcher gates the real-time increment at `SimpleXAPI.kt:2786-2789` on `cInfo.ntfsEnabled(cItem)` — the same per-chat predicate. So `users[non-active].unreadCount` is always equal to "messages this user wants to be notified about," with no `listUsers` round-trip required after each new message.
|
||||
|
||||
The active profile takes a different real-time path: the dispatcher's `if (active(r.user))` branch (`SimpleXAPI.kt:2778-2785`) calls `addChatItem`, and `addChatItem` in turn calls `increaseUnreadCounter(rhId, currentUser.value!!)` at `ChatModel.kt:559-562` with **no** `ntfsEnabled` gate. Every `RcvNew` item — including ones in muted chats — bumps `users[active].unreadCount` by 1, and the inflation is only reconciled when the next `listUsers` refresh runs the mute-filtered SQL again. The per-chat walk in the taskbar derivation bypasses that drift entirely: it reads `chatStats.unreadCount`/`unreadMentions` (raw per-chat counters that are accurate by construction) and applies the `enableNtfs` filter at compute time, producing a number that is correct under both new messages and mute toggles.
|
||||
|
||||
### Why `derivedStateOf`
|
||||
|
||||
Both `ChatModel.users` (a `SnapshotStateList<UserInfo>` from `mutableStateListOf`) and `ChatModel.chats.value` (a `SnapshotStateList<Chat>` reached via a `State<List<Chat>>` wrapper) are observable Compose state. `derivedStateOf` re-runs the lambda whenever any read of those snapshots is invalidated, and only emits a new value when the computed integer actually differs — so individual chat-list edits that don't shift the total (e.g. a message arriving in a fully-muted chat) re-run the lambda but do not cause the title to recompose. The `remember { ... }` keeps the derivation alive across recompositions.
|
||||
|
||||
### Reading `chats` from a composable
|
||||
|
||||
`ChatModel.chats` is declared as `val chats: State<List<Chat>> = chatsContext.chats` (`ChatModel.kt:134`). `chatsContext` is itself a `val` (not reassigned). On profile switch and on `startChat`'s "running" branch, `ChatsContext.updateChats` (`ChatModel.kt:468-488`; `SimpleXAPI.kt:585-587`) calls the `replaceAll` extension (`ChatModel.kt:3329`), which constructs a fresh `SnapshotStateList<Chat>` and assigns it to `chats.value`. `derivedStateOf` reads `ChatModel.chats.value` each time it recomputes, so it always observes the active profile's current chats — both the new-list assignment and per-chat edits (like `chats[i] = chat.copy(...)` at `ChatModel.kt:556`) invalidate the derivation.
|
||||
|
||||
## Files changed
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt` | Replace `title = "SimpleX"` (line 130) with a `title = title` parameter computed from a `derivedStateOf { … }` block immediately above the `Window(…)` call. `MsgFilter` is reachable via the existing wildcard `import chat.simplex.common.model.*` (line 16); no new import is needed because we qualify the enum constants (`MsgFilter.All`, etc.). |
|
||||
|
||||
That is the only file changed. The two file-chooser dialog titles (`DesktopApp.kt:136, 147, 158`) and the developer Terminal window title (line 213) remain as-is per the spec's scope.
|
||||
|
||||
No new resources, no new strings, no `build.gradle.kts` change, no `commonMain` change, no Android-side change.
|
||||
|
||||
## Specific things to watch
|
||||
|
||||
- **Title at startup.** Before the active profile's chats load, `ChatModel.chats.value` is empty and the active-profile contribution is 0. If `ChatModel.users` is already populated (via `listUsers`), the title reflects only the non-active-profiles sum during that window; when the active chats arrive, the title updates to include the active-profile contribution. If neither is populated yet, the title is plain "SimpleX". Each population is a `replaceAll`/`addAll` invocation followed by one Compose frame; `derivedStateOf` re-computes once per frame, so intermediate empty states are not user-observable.
|
||||
- **Profile switch transient.** `changeActiveUser_` (`SimpleXAPI.kt:643-661`) writes the new currentUser, then refreshes `ChatModel.users` via `listUsers` (lines 653-655), then awaits `apiGetChats` for the new profile, and finally calls `updateChats` which `replaceAll`s `chats.value`. There is a brief window between the `users` refresh and the chats replacement (~one round-trip to the core) where `chats.value` still holds the OLD profile's chats but `users[OLD].activeUser` has already flipped to `false` — so the OLD profile contributes once via `active` (its old chat list) and once via `others` (`users[OLD].unreadCount`, just refreshed). The title can transiently appear inflated during a profile switch and settles correctly once `updateChats` runs. Reviewer-visible behavior, but invisible to users in normal use; aligning the refresh order (load new chats before flipping the users list) is out of scope for this PR.
|
||||
- **Chat controller stopped.** When `chatRunning.value == false`, no new messages arrive; the existing model state is shown verbatim. The title does not get a "stopped" decoration — that is the responsibility of the in-app stopped banner, not the taskbar.
|
||||
- **Programmatic title overwrites.** Nothing else in the desktop code writes to the main `ComposeWindow`'s title. Grep on `\.title = ` inside `desktopMain` returns exactly one hit — `DefaultDialog.desktop.kt:148` setting `this.title = title` on a `FileDialog` instance (a separate transient AWT window, not the main window) — so there is no risk of an out-of-band write racing with the composable.
|
||||
- **No interaction with the tray code.** `DesktopTray.kt`'s `unread` derivation is independent and continues to use its existing rule (sum `UserInfo.unreadCount`, skip non-active muted profiles). Both surfaces read `ChatModel.users` and recompute on the same edits; no shared mutable state.
|
||||
|
||||
## Why not share a helper with the tray
|
||||
|
||||
The two surfaces produce the same total in steady state but differ in two transient places:
|
||||
|
||||
1. **Active profile, between `listUsers` refreshes.** The tray uses `UserInfo.unreadCount` for the active profile too — the same in-memory aggregate that the increment at `ChatModel.kt:560` drifts upward on every received item regardless of `enable_ntfs`. The taskbar walks `ChatModel.chats` and gates each chat's contribution on `enableNtfs` at compute time, so a mute toggle or a message in a muted chat is reflected immediately rather than at the next `listUsers` round-trip.
|
||||
2. **Non-active hidden profiles, on paper.** The tray's lambda is `if (!showNtfs && !activeUser) 0 else unreadCount` — no explicit `User.hidden` filter. The taskbar adds one. The core enforces "hidden user always muted when inactive" (`CEHiddenUserAlwaysMuted`; `View.hs:333` matches on `(showNtfs && isNothing viewPwdHash) || activeUser`), so the tray's `!showNtfs` already covers every non-active hidden profile and the observed counts coincide. The taskbar's `!hidden` is a redundant-but-explicit safety belt: it documents the predicate at the call site rather than relying on the reader to know the core invariant.
|
||||
|
||||
Two independent rule differences. Extracting a shared helper today would either parameterize over both — which is just two functions sitting in the same file — or force one of the surfaces to adopt the other's rule, which is a deliberate product decision that belongs in its own PR (see spec, "Out of scope: Updating the tray-icon tooltip / tray-dot logic"). The taskbar derivation lives next to its consumer in `DesktopApp.kt` for now.
|
||||
|
||||
## Diff shape
|
||||
|
||||
- One file changed.
|
||||
- ~15 lines added (the `derivedStateOf` block + the title-format expression).
|
||||
- 1 line modified (the `title = "SimpleX"` parameter).
|
||||
- 0 lines removed.
|
||||
|
||||
This is a behavior-change commit: one logical addition, one surface, one file. It is reviewable in under three minutes.
|
||||
|
||||
## Test plan
|
||||
|
||||
Manual verification on each platform (Linux KDE Plasma, Windows 11, macOS) using a single profile and then a multi-profile setup. The taskbar / window-list entry is the visual surface — verify by reading it.
|
||||
|
||||
1. **Fresh install, no unread.** App opens. Title bar reads "SimpleX". Taskbar / dock entry reads "SimpleX".
|
||||
2. **Receive an unmuted message.** Title transitions to "SimpleX [1]" as soon as the message arrives, both in the title bar and in the taskbar / window-list / Alt-Tab switcher entry. Receive a second: "SimpleX [2]".
|
||||
3. **Read the chat.** Title returns to "SimpleX".
|
||||
4. **Mute a chat with unread.** Mute the chat (`All` → `None`) while it has 3 unread messages. Title decrements by 3.
|
||||
5. **Partially-mute (Mentions).** Chat has 5 unread, 1 of which mentions you. Set `All` → `Mentions`. Title goes from "SimpleX [5]" to "SimpleX [1]". Reply to the mention. Title returns to "SimpleX". Receive a regular message (not a mention): title stays "SimpleX". Receive a mention/reply: "SimpleX [1]".
|
||||
6. **Mark unread (`unreadChat` only, zero items).** Long-press an empty chat → Mark unread. Title does *not* change. The chat-list shows the unread indicator but the taskbar suffix does not increment (per spec, "Skipped categories").
|
||||
7. **Second profile, unmuted.** Add Profile B. Switch back to Profile A. Send a message from another device to Profile B (to a non-muted chat). Title in Profile A's window transitions to "SimpleX [1]" immediately — `users[B].unreadCount` is incremented in real time at `SimpleXAPI.kt:2786-2789` when `cInfo.ntfsEnabled(cItem)` passes. Send a second message: "SimpleX [2]". Switch to Profile B, read the chat. Title returns to "SimpleX".
|
||||
8. **Second profile, muted.** Set Profile B's `showNtfs = false` (the profile-level mute, the muted-icon row in the picker). With Profile A active, receive a message to B. Title does **not** change. The profile picker still shows the badge in B's row (with the muted-color), but the taskbar — like the tray icon — is silent for muted non-active profiles. Re-enable B's notifications (`showNtfs = true`); title transitions to "SimpleX [1]" without any new message.
|
||||
9. **Hidden profile.** Hide Profile B (set a passphrase). With Profile A active, receive a message to B. Title does *not* change. The profile picker also does not show B.
|
||||
10. **Switch active profile.** With unread in both A (3, active, unmuted) and B (2, non-active, not hidden), title reads "SimpleX [5]". Switch to B. Title reads "SimpleX [5]" (now via B-active-path + A-non-active-path).
|
||||
11. **File chooser dialog.** Trigger a file dialog (send a file). The dialog's title bar reads "SimpleX" (no count), per scope. The main window's title bar still reads "SimpleX [N]" if applicable.
|
||||
12. **Stop the chat controller.** Settings → stop chat. The title shows whatever the model currently has; no count change from the stop itself.
|
||||
|
||||
The Linux test should be done on at least one non-GNOME desktop (Plasma / XFCE / Cinnamon) to verify the taskbar/window-list entry — stock GNOME's overview is the same `Frame.title` surface as the rest, so it works without the tray probe.
|
||||
@@ -0,0 +1,94 @@
|
||||
# Desktop taskbar unread count — product spec
|
||||
|
||||
## What
|
||||
|
||||
When the SimpleX desktop app has unread, non-muted notifications, the operating system's window/taskbar title for the app changes from
|
||||
|
||||
> SimpleX
|
||||
|
||||
to
|
||||
|
||||
> SimpleX [N]
|
||||
|
||||
where `N` is the total unread count across the user's profiles, applying each chat's mute setting: a partially-muted chat (`Mentions`) contributes only its mention/reply count, a fully-muted chat (`None`) contributes nothing, an unmuted chat (`All`) contributes its full unread count. The same per-mute-mode dispatch the in-app chat list uses to decide whether each chat shows a badge, applied to produce a number instead of a yes/no. The bracketed suffix disappears when `N` drops to zero — typically when the user reads or mutes the remaining unread chats. See "Counting rules" below for the exact formula.
|
||||
|
||||
The visible surface is the OS-provided window title, which appears in:
|
||||
|
||||
- Linux: the title bar of the SimpleX window and the per-app entry in the taskbar / dock / window-list of every desktop environment (KDE Plasma, GNOME, XFCE, etc.).
|
||||
- Windows: the window title bar and the per-app button on the taskbar / task switcher (Alt-Tab).
|
||||
- macOS: the window title bar and the per-window entry in the Window menu (the Dock badge on macOS is unaffected by window title — that is a separate API, out of scope here).
|
||||
|
||||
Scope: the main SimpleX window only. The transient file-chooser dialogs (`DesktopApp.kt:136, 147, 158`) and the optional developer Terminal window (`DesktopApp.kt:213`) keep their own titles unchanged — they are not "the app" and adding a count to them would be misleading.
|
||||
|
||||
## Why
|
||||
|
||||
We already ship two unread surfaces on desktop:
|
||||
|
||||
1. **OS notifications** — toasts via `displayNotificationViaLib` (TwoSlices).
|
||||
2. **Tray icon** — binary unread dot (`ic_simplex_tray_dot.svg`) plus a numeric tooltip ("SimpleX — N unread") added in commit `3c82c6c91`.
|
||||
|
||||
Neither tells a user who is in another window how many messages are waiting. The notification toasts disappear after a few seconds. The tray icon's dot is binary — the count lives only in the tooltip, which requires the user to hover over the tray icon to see it. The window title and taskbar entry, on the other hand, are visible in every workspace, in every window switcher, in the OS's own "what apps want my attention" surface — and they update at zero attention cost from the user.
|
||||
|
||||
Adding a numeric suffix to the taskbar gives users the information they already had on the tray icon (the count) on a surface they already glance at (the taskbar / window-list / Cmd-Tab). It is the canonical place to put per-app unread counts on every desktop platform we ship.
|
||||
|
||||
The existing tray-tooltip count and this taskbar count come from the same notion of "unread" and agree in steady state. The active-profile path differs in transient states — see "Out of scope: Updating the tray-icon tooltip / tray-dot logic" below.
|
||||
|
||||
## How
|
||||
|
||||
### Counting rules
|
||||
|
||||
The total is the sum of two parts:
|
||||
|
||||
**Active profile** — walk its chats; per chat, count according to that chat's notification setting (`ChatSettings.enableNtfs`):
|
||||
|
||||
| Chat's `enableNtfs` | What is counted | Source |
|
||||
|---|---|---|
|
||||
| `All` (unmuted) | all unread messages | `chatStats.unreadCount` |
|
||||
| `Mentions` (partially muted) | unread mentions and replies only | `chatStats.unreadMentions` |
|
||||
| `None` (fully muted) | nothing | 0 |
|
||||
|
||||
This parallels the existing per-chat `Chat.unreadTag` rule (`ChatModel.kt:1383-1387`), which decides whether a chat shows an unread badge in the chat list — both dispatch on the same `enableNtfs` cases. The difference: `unreadTag` produces a boolean, the taskbar produces a number; and the taskbar excludes the `unreadChat`-only case that `unreadTag` does include (see "Skipped categories" below). Note that the chat-list badge itself displays the *raw* `chatStats.unreadCount` regardless of mute mode (only the badge color reflects the mute); the taskbar's per-chat contribution is the mute-filtered number described in the table above, not the badge number.
|
||||
|
||||
**Other profiles (non-active)** — every non-active profile that is not hidden *and* not profile-level-muted contributes its full `UserInfo.unreadCount`. Profile-level mute (`User.showNtfs == false`) silences a profile entirely from cross-profile surfaces: the profile picker still shows the per-row badge with a different color so the user can see "there is unread over there", but the taskbar — like the existing tray icon (`DesktopTray.kt:62-68`) — does not contribute that profile's unread to the global count. Muting a profile is the user saying "I do not want this surfaced to me right now"; the taskbar honors that the same way the tray does.
|
||||
|
||||
Skipped categories:
|
||||
|
||||
- **Hidden profiles** (those gated behind a passphrase, `User.hidden == viewPwdHash != null`) — never counted, matching the profile picker's filter `users.filter { u -> u.user.activeUser || !u.user.hidden }`. A hidden profile that the user has unlocked into the *active* slot is no longer "hidden from this surface" and is counted via the active-profile path above (per-chat with mute filter), just like the picker shows it.
|
||||
- **Profile-level muted non-active profiles** (`!user.activeUser && !user.showNtfs`) — see above. The active profile is always counted regardless of `showNtfs`, because being active overrides the profile-level mute (`UserLike.showNotifications = activeUser || showNtfs`); per-chat mute still applies within it.
|
||||
- **Fully-muted chats** in the active profile (`enableNtfs == None`) — see table above.
|
||||
- **The `unreadChat` flag** (manually-marked-unread chats with zero items) — not counted. The taskbar suffix is a *number*, not a state, and a "marked unread, no messages" chat contributes 0 to a numeric sum just as it does in the tray tooltip today.
|
||||
|
||||
### Asymmetry between the active profile and the others
|
||||
|
||||
For the active profile we have the full chat list in memory; we walk it and apply the per-chat mute filter directly off the live `Chat.chatStats` and `chatInfo.chatSettings`. For non-active profiles we do not have their chats loaded — we use the per-profile aggregate `UserInfo.unreadCount`. Both produce mute-filtered numbers, but via different mechanisms:
|
||||
|
||||
1. **Non-active `UserInfo.unreadCount` is mute-filtered at every mutation point.** On load from core, `getUserInfo` (`src/Simplex/Chat/Store/Profiles.hs:175-196`) sums chat-item rows with a SQL `WHERE` that mirrors `MsgFilter` exactly: for direct contacts only `enable_ntfs = 1 OR NULL` (i.e. `All`), for groups only `enable_ntfs = 1 OR NULL OR (enable_ntfs = 2 AND user_mention = 1)` (i.e. `All` or `Mentions`-mode mentions). In real time, the Kotlin event dispatcher at `SimpleXAPI.kt:2786-2789` increments `users[r.user].unreadCount` only when `cInfo.ntfsEnabled(cItem)` passes — the same filter, applied at the call site before the increment runs. So a message arriving for a non-active profile in a `None` chat is silently dropped from the aggregate; one in an `All` chat increments it; one in a `Mentions` chat increments only if it is a mention/reply. The value stays correct without depending on `listUsers`.
|
||||
2. **It agrees with the tray's non-active rule.** The tray uses `!showNtfs && !activeUser → 0` (no explicit `.hidden` filter); the taskbar uses `!hidden && !activeUser && showNtfs ? unreadCount : 0`. The two formulations are not the same set on paper, but the core enforces "hidden user always muted when inactive" (`src/Simplex/Chat/View.hs:333`, `CEHiddenUserAlwaysMuted`) — a non-active hidden profile always has `showNtfs == false` from the core's perspective. So the tray's `!showNtfs` already absorbs every non-active hidden profile, and the two non-active sets coincide. The taskbar's `!hidden` clause is an explicit safety belt that does not depend on the core invariant being upheld for every code path that ever sets `showNtfs`.
|
||||
|
||||
Why walk the active profile's chats instead of using `UserInfo.unreadCount` for it too? Because the *active* profile takes a different real-time path. The active-user branch of the dispatcher (`SimpleXAPI.kt:2778-2785`) calls `addChatItem`, and inside `addChatItem` the call to `increaseUnreadCounter` (`ChatModel.kt:559-562`) fires for every `RcvNew` item with **no** `ntfsEnabled` gate. So `users[active].unreadCount` drifts upward whenever the active profile receives messages in muted chats, and is only reconciled on the next `listUsers` refresh. The per-chat walk avoids the drift: it reads `chatStats.unreadCount` and `unreadMentions` (which track raw chat-item state) and gates them at compute time on the chat's `enableNtfs`, producing a number that is correct under both new messages *and* mute toggles.
|
||||
|
||||
### Behavior in observable scenarios
|
||||
|
||||
| Event | Effect on title |
|
||||
|---|---|
|
||||
| App starts, two unread messages in the active profile's unmuted chat | "SimpleX [2]" appears as soon as the chat list finishes loading. |
|
||||
| User opens the chat and reads the messages | Title returns to "SimpleX" as `chatStats.unreadCount` decrements. |
|
||||
| User mutes a chat with unread messages (`All` → `None`) | Those messages stop contributing; title decrements. |
|
||||
| User partially-mutes a chat (`All` → `Mentions`) with unread non-mention messages and one unread mention | Contribution drops from `unreadCount` to `unreadMentions` (1). |
|
||||
| A non-active, non-hidden profile receives a message in a non-muted chat | Title increments immediately. The dispatcher at `SimpleXAPI.kt:2786-2789` increments `users[X].unreadCount` in real time when `cInfo.ntfsEnabled(cItem)` passes (the same per-chat mute filter the core uses). For `Mentions`-mode chats only mention/reply items trigger the increment; for `None`-mode chats no increment fires, so the title stays unchanged. |
|
||||
| A hidden profile receives a message | Title is unchanged. The profile picker also hides this. |
|
||||
| User switches active profile from A to B | Title recomputes for B's active-profile path and A's non-active path. |
|
||||
| Chat controller stopped (no live messages) | Title shows whatever the model currently holds; no special "stopped" state. |
|
||||
| Count exceeds 4 digits ("SimpleX [12345]") | Rendered verbatim. No truncation or "+99". OS may ellipsize in the taskbar; the title bar shows full. |
|
||||
|
||||
### Out of scope
|
||||
|
||||
- **macOS Dock badge.** That is a separate API (`NSApp.dockTile.badgeLabel`) and a separate UX surface (a red badge on the Dock icon, not the window title). A Dock badge belongs in its own PR — it has different platform constraints, different positioning, and the question of whether it should mirror the tray dot's binary indicator or the taskbar's number is its own design decision.
|
||||
- **Updating the tray-icon tooltip / tray-dot logic.** The taskbar count and the tray-tooltip count agree in steady state but differ in two transient places:
|
||||
- **Active profile, between `listUsers` refreshes.** The tray uses `UserInfo.unreadCount` for the active profile too — the same in-memory aggregate that drifts upward on every received chat item regardless of `enable_ntfs` (`ChatModel.kt:560`). The taskbar walks the active profile's chats and applies the per-chat `enableNtfs` filter at compute time. When the user mutes a chat or receives a message in a muted chat, the taskbar reflects the correct count immediately; the tray's tooltip is corrected only on the next `listUsers` round-trip (startup, profile switch, picker open, hide/unhide). The steady-state values agree.
|
||||
- **Non-active hidden profiles, on paper.** The tray's lambda is `if (!showNtfs && !activeUser) 0 else unreadCount` — it does not explicitly filter `User.hidden`. The taskbar does. In practice the core enforces "hidden user always muted when inactive" (`src/Simplex/Chat/View.hs:333`, `CEHiddenUserAlwaysMuted`), so a non-active hidden profile always has `showNtfs == false` and both surfaces zero it. The rules differ on paper; the observed counts do not.
|
||||
|
||||
Aligning the tray's lambda to the taskbar's (or vice versa) is its own product decision and belongs in its own PR. This PR introduces the taskbar count alongside the existing tray and leaves the tray rule untouched.
|
||||
- **Per-platform title format variants** (e.g. "SimpleX (3)" on macOS, "(3) SimpleX" on Windows). One format on all platforms is simpler, easier to test, and easier to localize. We use `"SimpleX [N]"` everywhere.
|
||||
- **Configurability.** No setting to turn this off. It is the same information the tray already exposes, presented on a surface that is always visible. A user who does not want a count anywhere can already mute their chats.
|
||||
- **Counting `unreadChat`** (manually-marked-unread chats with zero items). The taskbar suffix is a number; this flag is a state. Leaving it out keeps the suffix faithful to "messages waiting".
|
||||
Reference in New Issue
Block a user