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:
@@ -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
|
||||
|
||||
11
src/app.h
11
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<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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user