feat(chat): mute conversations (Q10)

Per-conversation mute toggle in the thread header. Muted conversations (tracked
by cid in settings, so it persists) are skipped by chatUnreadCount(), so they
neither raise the nav-item unread badge nor the new-message toast — the toast now
gates on a chatUnreadCount() delta across ingest, which already skips muted cids,
so mute is respected for free. "Block" (rejecting a peer's inbound memos) is a
larger ingest-filter change and is intentionally left out of this pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-16 16:48:57 -05:00
parent fecc9015fb
commit 24c661f743
4 changed files with 40 additions and 4 deletions

View File

@@ -1233,6 +1233,7 @@ int App::chatUnreadCount() const
int unread = 0;
const auto& store = chat_service_.store();
for (const auto& cid : store.conversationIds()) {
if (settings_ && settings_->isChatMuted(cid)) continue; // muted conversations don't badge (Q10)
std::int64_t seen = 0;
const auto it = chat_seen_watermark_.find(cid);
if (it != chat_seen_watermark_.end()) seen = it->second;
@@ -1688,9 +1689,12 @@ void App::refreshTransactionData()
!result.hushChatMetadata.empty()) {
std::unordered_map<std::string, std::int64_t> chatTxTimes;
for (const auto& tx : result.transactions) chatTxTimes[tx.txid] = tx.timestamp;
const int unreadBefore = chatUnreadCount();
const int newMsgs = chat_service_.ingest(result.hushChatMetadata, chatTxTimes, std::time(nullptr));
if (newMsgs > 0 && current_page_ != ui::NavPage::Chat)
ui::Notifications::instance().info(TR("chat_new_message_toast")); // Q4: alert when not on the Chat tab
// Toast only when a non-muted conversation gained an unread message — chatUnreadCount()
// already skips muted cids, so a delta cleanly respects mute (Q10 + Q4).
if (newMsgs > 0 && current_page_ != ui::NavPage::Chat && chatUnreadCount() > unreadBefore)
ui::Notifications::instance().info(TR("chat_new_message_toast"));
}
NetworkRefreshService::applyTransactionRefreshResult(
state_, cacheUpdate, std::move(result), std::time(nullptr));
@@ -1748,9 +1752,12 @@ void App::refreshRecentTransactionData()
!result.hushChatMetadata.empty()) {
std::unordered_map<std::string, std::int64_t> chatTxTimes;
for (const auto& tx : result.transactions) chatTxTimes[tx.txid] = tx.timestamp;
const int unreadBefore = chatUnreadCount();
const int newMsgs = chat_service_.ingest(result.hushChatMetadata, chatTxTimes, std::time(nullptr));
if (newMsgs > 0 && current_page_ != ui::NavPage::Chat)
ui::Notifications::instance().info(TR("chat_new_message_toast")); // Q4: alert when not on the Chat tab
// Toast only when a non-muted conversation gained an unread message — chatUnreadCount()
// already skips muted cids, so a delta cleanly respects mute (Q10 + Q4).
if (newMsgs > 0 && current_page_ != ui::NavPage::Chat && chatUnreadCount() > unreadBefore)
ui::Notifications::instance().info(TR("chat_new_message_toast"));
}
NetworkRefreshService::applyTransactionRefreshResult(
state_, cacheUpdate, std::move(result), std::time(nullptr));

View File

@@ -147,6 +147,11 @@ bool Settings::load(const std::string& path)
loadScalar(j, "language", language_);
loadScalar(j, "skin_id", skin_id_);
loadScalar(j, "chat_reply_zaddr", chat_reply_zaddr_);
if (j.contains("muted_chat_cids") && j["muted_chat_cids"].is_array()) {
muted_chat_cids_.clear();
for (const auto& c : j["muted_chat_cids"])
if (c.is_string()) muted_chat_cids_.push_back(c.get<std::string>());
}
loadScalar(j, "acrylic_enabled", acrylic_enabled_);
loadScalar(j, "acrylic_quality", acrylic_quality_);
loadScalar(j, "blur_multiplier", blur_multiplier_);
@@ -422,6 +427,9 @@ bool Settings::save(const std::string& path)
j["language"] = language_;
j["skin_id"] = skin_id_;
j["chat_reply_zaddr"] = chat_reply_zaddr_;
j["muted_chat_cids"] = json::array();
for (const auto& c : muted_chat_cids_)
j["muted_chat_cids"].push_back(c);
j["acrylic_enabled"] = acrylic_enabled_;
j["acrylic_quality"] = acrylic_quality_;
j["blur_multiplier"] = blur_multiplier_;

View File

@@ -118,6 +118,18 @@ public:
std::string getChatReplyZaddr() const { return chat_reply_zaddr_; }
void setChatReplyZaddr(const std::string& z) { chat_reply_zaddr_ = z; }
// Muted chat conversations (by cid) — muted conversations don't badge or raise a toast (Q10).
bool isChatMuted(const std::string& cid) const {
return std::find(muted_chat_cids_.begin(), muted_chat_cids_.end(), cid) != muted_chat_cids_.end();
}
void setChatMuted(const std::string& cid, bool muted) {
const bool already = isChatMuted(cid);
if (muted && !already) muted_chat_cids_.push_back(cid);
else if (!muted && already)
muted_chat_cids_.erase(std::remove(muted_chat_cids_.begin(), muted_chat_cids_.end(), cid),
muted_chat_cids_.end());
}
// Privacy
bool getSaveZtxs() const { return save_ztxs_; }
void setSaveZtxs(bool save) { save_ztxs_ = save; }
@@ -473,6 +485,7 @@ private:
std::string theme_ = "dragonx";
std::string skin_id_ = "dragonx";
std::string chat_reply_zaddr_;
std::vector<std::string> muted_chat_cids_; // muted chat conversations by cid (Q10)
bool save_ztxs_ = true;
bool auto_shield_ = true;
bool use_tor_ = false;

View File

@@ -11,6 +11,7 @@
#include "../../chat/chat_service.h"
#include "../../util/i18n.h"
#include "../../util/platform.h" // getConfigDir + writeFileAtomically — conversation export (Q11)
#include "../../config/settings.h" // per-conversation mute (Q10)
#include "../material/colors.h"
#include "../material/color_theme.h" // WithAlpha
#include "../material/type.h"
@@ -431,6 +432,13 @@ void RenderChatTab(App* app)
else
Notifications::instance().error(TR("chat_export_failed"));
}
// Mute toggle — muted conversations don't badge or toast (Q10). Persisted to settings.
ImGui::SameLine();
const bool muted = app->settings() && app->settings()->isChatMuted(sel->cid);
if (ImGui::SmallButton(muted ? TR("chat_unmute") : TR("chat_mute")) && app->settings()) {
app->settings()->setChatMuted(sel->cid, !muted);
app->settings()->save();
}
}
ImGui::Separator();