feat(chat): unread count + sidebar badge (Q1)

Track a per-conversation "last seen" watermark (message timestamp) on App:
- chatUnreadCount() sums incoming messages newer than each conversation's
  watermark; surfaced as SidebarStatus.chatUnreadCount → a badge on the Chat nav
  item (mirrors the History/Peers badges).
- Viewing a thread marks it seen (markChatConversationSeen while displayed).
- Baseline on load: existing stored messages are marked seen, so only messages
  that arrive while the app is open badge as unread.
- Wiped in resetChatSession so unread state never leaks across a wallet switch.
In-memory only (resets on app restart); persistence is a later refinement.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-16 15:56:58 -05:00
parent 055685a4c4
commit 100ed01469
5 changed files with 44 additions and 0 deletions

View File

@@ -1503,6 +1503,7 @@ void App::render()
ui::SidebarStatus sbStatus;
sbStatus.peerCount = static_cast<int>(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

View File

@@ -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<std::string, std::int64_t> 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.

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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());