diff --git a/src/app.cpp b/src/app.cpp index 2031c7a..2048e00 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -1503,6 +1503,7 @@ void App::render() ui::SidebarStatus sbStatus; sbStatus.peerCount = static_cast(state_.peers.size()); sbStatus.miningActive = state_.mining.generate || state_.pool_mining.xmrig_running; + sbStatus.chatUnreadCount = chatUnreadCount(); // unread badge on the Chat nav item (Q1) // Load logo texture lazily on first frame (or after theme change) // Also reload when dark↔light mode changes so the correct variant shows diff --git a/src/app.h b/src/app.h index 58065c9..dbbdb6c 100644 --- a/src/app.h +++ b/src/app.h @@ -679,6 +679,17 @@ private: // no longer matches — so wallet A's seed can't be provisioned under wallet B via a stale job. int chat_session_generation_ = 0; bool chat_identity_unavailable_ = false; // provisioning failed definitively (e.g. non-mnemonic wallet) + // Per-conversation "last seen" watermark (message timestamp) for unread tracking (Q1). Updated when a + // thread is viewed (markChatConversationSeen); wiped in resetChatSession so unread doesn't leak across + // wallets. In-memory only (resets on app restart). + std::map chat_seen_watermark_; +public: + // Total unread incoming chat messages across all conversations (for the sidebar badge). 0 when the + // feature is off / no identity. + int chatUnreadCount() const; + // Mark a conversation read up to latestTs (called by the Chat tab while a thread is displayed). + void markChatConversationSeen(const std::string& cid, std::int64_t latestTs); +private: // Provision the chat identity once the wallet seed is reachable+unlocked (per-tick, both // variants); derives via deriveChatIdentityFromSecret and wipes the secret. No-op when the // feature is off, already provisioned, in flight, or unavailable. diff --git a/src/app_network.cpp b/src/app_network.cpp index cc708a8..087a11e 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -1227,6 +1227,26 @@ void App::processWalletSwitchRevert() daemon_restarting_ = false; // the connect loop now restarts the previous wallet's daemon } +int App::chatUnreadCount() const +{ + if (!chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return 0; + int unread = 0; + const auto& store = chat_service_.store(); + for (const auto& cid : store.conversationIds()) { + std::int64_t seen = 0; + const auto it = chat_seen_watermark_.find(cid); + if (it != chat_seen_watermark_.end()) seen = it->second; + for (const auto& m : store.conversation(cid)) + if (m.direction == chat::ChatDirection::Incoming && m.timestamp > seen) ++unread; + } + return unread; +} + +void App::markChatConversationSeen(const std::string& cid, std::int64_t latestTs) +{ + if (latestTs > 0) chat_seen_watermark_[cid] = latestTs; +} + void App::wipePendingTransactionHistoryCachePassphrase() { if (!pending_transaction_history_cache_passphrase_.empty()) { @@ -2658,6 +2678,13 @@ void App::provisionChatIdentityFromSecret(std::string secret) chat_service_.setPersistence(&chat_db_); if (chat_db_.unlockWithSecret(trimmed)) { chat_service_.loadFromDatabase(); + // Baseline unread: treat everything already in the store at load as read, so only messages + // that arrive while the app is open badge as unread (Q1). + const auto& store = chat_service_.store(); + for (const auto& cid : store.conversationIds()) { + const auto msgs = store.conversation(cid); + if (!msgs.empty()) chat_seen_watermark_[cid] = msgs.back().timestamp; + } } } else { chat_identity_unavailable_ = true; @@ -2677,6 +2704,7 @@ void App::resetChatSession() // Drop identity + decrypted plaintext in RAM, lock the seed-encrypted DB, re-arm provisioning. chat_service_.clearIdentity(); chat_service_.store().clear(); + chat_seen_watermark_.clear(); // unread state is per-wallet — don't leak it across a switch ui::ResetChatTab(); // wipe the chat UI's typed plaintext + selection so it can't leak into the next wallet chat_db_.lock(); chat_identity_provisioned_ = false; diff --git a/src/ui/sidebar.h b/src/ui/sidebar.h index 1e81cfe..14a56d4 100644 --- a/src/ui/sidebar.h +++ b/src/ui/sidebar.h @@ -149,6 +149,7 @@ struct SidebarStatus { int unconfirmedTxCount = 0; // badge on History bool miningActive = false; // green dot on Mining int peerCount = 0; // badge on Peers + int chatUnreadCount = 0; // badge on Chat (unread incoming messages) // Exit bool exitClicked = false; // Branding logo (optional — loaded at startup) @@ -715,6 +716,8 @@ inline bool RenderSidebar(NavPage& current, float sidebarWidth, float contentHei dotOnly = true; badgeCol = Success(); } else if (item.page == NavPage::Peers && status.peerCount > 0) { badgeCount = status.peerCount; + } else if (item.page == NavPage::Chat && status.chatUnreadCount > 0) { + badgeCount = status.chatUnreadCount; } if (badgeCount > 0 || dotOnly) { diff --git a/src/ui/windows/chat_tab.cpp b/src/ui/windows/chat_tab.cpp index 91cbb00..e6f95ef 100644 --- a/src/ui/windows/chat_tab.cpp +++ b/src/ui/windows/chat_tab.cpp @@ -275,6 +275,7 @@ void RenderChatTab(App* app) for (const auto& c : convs) if (c.cid == s_selected_cid) { sel = &c; break; } if (sel) { + app->markChatConversationSeen(sel->cid, sel->lastTs); // viewing the thread clears its unread (Q1) // Header: peer name + z-address. ImGui::PushFont(material::Type().subtitle1()); ImGui::TextUnformatted(sel->peerName.c_str());