Compare commits
3
Commits
d27f387d6d
...
d9fa00bb38
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9fa00bb38 | ||
|
|
0a042df8e0 | ||
|
|
a60a2f8e39 |
+20
-5
@@ -1806,7 +1806,10 @@ void App::render()
|
||||
// long confirmed (the other leg holds the real count). So: a txid with ANY confirmed leg is
|
||||
// confirmed, and we count UNIQUE unconfirmed txids — otherwise the badge sticks on stale 0-conf
|
||||
// legs of already-confirmed transactions and double-counts multi-leg ones.
|
||||
{
|
||||
// Recompute only when the tx list actually changed (last_tx_update is bumped on every tx refresh;
|
||||
// size() catches same-second content changes). Otherwise this built two unordered_sets over the whole
|
||||
// wallet tx list every frame just to size a badge.
|
||||
if (state_.last_tx_update != sb_unconf_key_ts_ || state_.transactions.size() != sb_unconf_key_n_) {
|
||||
std::unordered_set<std::string> confirmedTxids;
|
||||
for (const auto& tx : state_.transactions) {
|
||||
if (tx.confirmations >= 1) confirmedTxids.insert(tx.txid);
|
||||
@@ -1817,8 +1820,11 @@ void App::render()
|
||||
unconfirmedTxids.insert(tx.txid);
|
||||
}
|
||||
}
|
||||
sbStatus.unconfirmedTxCount = static_cast<int>(unconfirmedTxids.size());
|
||||
sb_unconf_count_ = static_cast<int>(unconfirmedTxids.size());
|
||||
sb_unconf_key_ts_ = state_.last_tx_update;
|
||||
sb_unconf_key_n_ = state_.transactions.size();
|
||||
}
|
||||
sbStatus.unconfirmedTxCount = sb_unconf_count_;
|
||||
|
||||
// Sidebar minimum height from ui.toml schema (DPI-scaled).
|
||||
const float sbMinHeight = sbde("min-height", 360.0f);
|
||||
@@ -5695,17 +5701,26 @@ bool App::stopDaemonForBootstrap()
|
||||
|
||||
double App::getDaemonMemoryUsageMB() const
|
||||
{
|
||||
// The Mining tab reads this every frame, but the probe is expensive (Linux embedded/external paths
|
||||
// scan /proc; macOS forks a shell). Throttle to ~1.5s so a 60Hz caller doesn't hammer the OS.
|
||||
const double now = ImGui::GetTime();
|
||||
if (daemon_mem_probe_at_ > 0.0 && now - daemon_mem_probe_at_ < 1.5)
|
||||
return daemon_mem_cached_mb_;
|
||||
daemon_mem_probe_at_ = now;
|
||||
|
||||
double result = 0.0;
|
||||
// If we have an embedded daemon with a tracked process handle, use it
|
||||
// directly — more reliable than a process scan since we own the handle.
|
||||
if (daemon_controller_ && daemon_controller_->isRunning()) {
|
||||
double mb = daemon_controller_->memoryUsageMB();
|
||||
daemon_mem_diag_ = "embedded";
|
||||
if (mb > 0.0) return mb;
|
||||
result = (mb > 0.0) ? mb : util::Platform::getDaemonMemoryUsageMB();
|
||||
} else {
|
||||
daemon_mem_diag_ = "process scan";
|
||||
result = util::Platform::getDaemonMemoryUsageMB(); // external daemon
|
||||
}
|
||||
// Fall back to platform-level process scan (external daemon)
|
||||
return util::Platform::getDaemonMemoryUsageMB();
|
||||
daemon_mem_cached_mb_ = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -760,6 +760,22 @@ private:
|
||||
// wallets. In-memory only (resets on app restart).
|
||||
std::map<std::string, std::int64_t> chat_seen_watermark_;
|
||||
|
||||
// ── Per-frame render caches (avoid O(N) recompute every frame; see the respective call sites) ──
|
||||
// Chat nav-badge unread count — recomputed only when the store revision changes or after a short
|
||||
// interval (mute/hide/seen changes don't bump the store). See App::chatUnreadCount().
|
||||
mutable std::uint64_t chat_unread_rev_ = ~0ull; // ~0 forces the first compute
|
||||
mutable int chat_unread_cached_ = 0;
|
||||
mutable double chat_unread_computed_at_ = 0.0;
|
||||
// Sidebar unconfirmed-tx badge — recomputed only when the tx list changes (keyed on last_tx_update +
|
||||
// size), not every frame. See App::render().
|
||||
std::int64_t sb_unconf_key_ts_ = -1;
|
||||
std::size_t sb_unconf_key_n_ = 0;
|
||||
int sb_unconf_count_ = 0;
|
||||
// Daemon-memory probe is expensive (/proc scan on Linux, popen on macOS); throttle it to ~1.5s so the
|
||||
// Mining tab's per-frame read doesn't hammer the OS. See App::getDaemonMemoryUsageMB().
|
||||
mutable double daemon_mem_cached_mb_ = 0.0;
|
||||
mutable double daemon_mem_probe_at_ = 0.0;
|
||||
|
||||
// ── Chat note buffer (BOTH variants) ────────────────────────────────────────────────────────
|
||||
// Each chat message is a shielded tx that spends a note; its change needs a few confirmations before
|
||||
// it's spendable again (lite: backend ANCHOR_OFFSET+1 = 5; full node: z_sendmany minconf = 1), so
|
||||
@@ -835,6 +851,9 @@ public:
|
||||
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);
|
||||
// Drop the seen-watermark for a conversation (used on revive-delete so a re-imported message — even one
|
||||
// whose stamped time predates the deleted thread's last message — still badges as unread).
|
||||
void forgetChatConversationSeen(const std::string& cid);
|
||||
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
|
||||
|
||||
+27
-10
@@ -1458,18 +1458,26 @@ void App::processWalletSwitchRevert()
|
||||
int App::chatUnreadCount() const
|
||||
{
|
||||
if (!chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return 0;
|
||||
int unread = 0;
|
||||
// This is called every frame from render() to size the Chat nav badge. Recompute only when the store
|
||||
// actually changed (new/removed messages bump revision()) or after a short interval (to pick up
|
||||
// mute/hide/seen changes, which don't bump the store). Previously it ran a full O(conversations x
|
||||
// messages) scan that COPIED and stable_sorted every conversation's messages, every frame.
|
||||
const auto& store = chat_service_.store();
|
||||
for (const auto& cid : store.conversationIds()) {
|
||||
if (settings_ && settings_->isChatMuted(cid)) continue; // muted conversations don't badge (Q10)
|
||||
if (settings_ && settings_->isChatHidden(cid)) continue; // hidden conversations don't badge
|
||||
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;
|
||||
const std::uint64_t rev = store.revision();
|
||||
const double now = ImGui::GetTime();
|
||||
if (rev != chat_unread_rev_ || now - chat_unread_computed_at_ > 0.25) {
|
||||
chat_unread_cached_ = store.countUnread(
|
||||
[this](const std::string& cid) { // excluded from the badge
|
||||
return settings_ && (settings_->isChatMuted(cid) || settings_->isChatHidden(cid));
|
||||
},
|
||||
[this](const std::string& cid) -> std::int64_t { // seen watermark
|
||||
const auto it = chat_seen_watermark_.find(cid);
|
||||
return it != chat_seen_watermark_.end() ? it->second : 0;
|
||||
});
|
||||
chat_unread_rev_ = rev;
|
||||
chat_unread_computed_at_ = now;
|
||||
}
|
||||
return unread;
|
||||
return chat_unread_cached_;
|
||||
}
|
||||
|
||||
void App::markChatConversationSeen(const std::string& cid, std::int64_t latestTs)
|
||||
@@ -1477,6 +1485,11 @@ void App::markChatConversationSeen(const std::string& cid, std::int64_t latestTs
|
||||
if (latestTs > 0) chat_seen_watermark_[cid] = latestTs;
|
||||
}
|
||||
|
||||
void App::forgetChatConversationSeen(const std::string& cid)
|
||||
{
|
||||
chat_seen_watermark_.erase(cid);
|
||||
}
|
||||
|
||||
void App::wipePendingTransactionHistoryCachePassphrase()
|
||||
{
|
||||
if (!pending_transaction_history_cache_passphrase_.empty()) {
|
||||
@@ -3041,6 +3054,10 @@ void App::provisionChatIdentityFromSecret(std::string secret)
|
||||
// Persistence: unlock the seed-derived chat DB with the SAME secret and rehydrate the store
|
||||
// with prior messages (decrypted at rest under a key only this seed can derive).
|
||||
chat_service_.setPersistence(&chat_db_);
|
||||
// A blocked conversation's messages are dropped at ingest (old + future) until it is unblocked.
|
||||
chat_service_.setBlockedPredicate([this](const std::string& cid) {
|
||||
return settings_ && settings_->isChatBlocked(cid);
|
||||
});
|
||||
if (chat_db_.unlockWithSecret(trimmed)) {
|
||||
chat_service_.loadFromDatabase();
|
||||
// Baseline unread: treat everything already in the store at load as read, so only messages
|
||||
|
||||
@@ -89,6 +89,7 @@ bool ChatDatabase::unlockWithSecret(const std::string& secret)
|
||||
lock();
|
||||
return false;
|
||||
}
|
||||
loadTombstones();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -97,11 +98,13 @@ void ChatDatabase::lock()
|
||||
sodium_memzero(key_.data(), key_.size());
|
||||
key_ready_ = false;
|
||||
wallet_tag_.clear();
|
||||
tombstones_.clear();
|
||||
}
|
||||
|
||||
bool ChatDatabase::append(const ChatMessage& message)
|
||||
{
|
||||
if (!key_ready_ || !ensureOpen()) return false;
|
||||
if (isTombstoned(message)) return false; // locally deleted — don't re-persist on a chain re-scan
|
||||
|
||||
std::vector<unsigned char> nonce;
|
||||
std::vector<unsigned char> cipher;
|
||||
@@ -193,13 +196,79 @@ std::vector<ChatMessage> ChatDatabase::load()
|
||||
void ChatDatabase::clearWallet()
|
||||
{
|
||||
if (wallet_tag_.empty() || !ensureOpen()) return;
|
||||
for (const char* sql : {"DELETE FROM chat_messages WHERE wallet_tag = ?",
|
||||
"DELETE FROM chat_deleted WHERE wallet_tag = ?"}) {
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) continue;
|
||||
sqlite3_bind_text(stmt, 1, wallet_tag_.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_step(stmt);
|
||||
sqlite3_finalize(stmt);
|
||||
}
|
||||
tombstones_.clear();
|
||||
}
|
||||
|
||||
bool ChatDatabase::deleteMessages(const std::vector<ChatMessage>& messages, bool tombstone)
|
||||
{
|
||||
if (!key_ready_ || !ensureOpen()) return false;
|
||||
if (messages.empty()) return true;
|
||||
|
||||
if (!exec("BEGIN")) return false;
|
||||
bool ok = true;
|
||||
for (const auto& m : messages) {
|
||||
const std::string dedup = dedupHash(m.txid, m.payload_position);
|
||||
|
||||
sqlite3_stmt* del = nullptr;
|
||||
if (sqlite3_prepare_v2(db_, "DELETE FROM chat_messages WHERE wallet_tag = ? AND dedup_hash = ?",
|
||||
-1, &del, nullptr) == SQLITE_OK) {
|
||||
sqlite3_bind_text(del, 1, wallet_tag_.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(del, 2, dedup.c_str(), -1, SQLITE_TRANSIENT);
|
||||
if (sqlite3_step(del) != SQLITE_DONE) ok = false;
|
||||
sqlite3_finalize(del);
|
||||
} else {
|
||||
ok = false;
|
||||
}
|
||||
|
||||
if (tombstone) {
|
||||
sqlite3_stmt* ins = nullptr;
|
||||
if (sqlite3_prepare_v2(db_,
|
||||
"INSERT OR IGNORE INTO chat_deleted (wallet_tag, dedup_hash) VALUES (?, ?)",
|
||||
-1, &ins, nullptr) == SQLITE_OK) {
|
||||
sqlite3_bind_text(ins, 1, wallet_tag_.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(ins, 2, dedup.c_str(), -1, SQLITE_TRANSIENT);
|
||||
if (sqlite3_step(ins) != SQLITE_DONE) ok = false;
|
||||
sqlite3_finalize(ins);
|
||||
} else {
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!exec(ok ? "COMMIT" : "ROLLBACK")) ok = false;
|
||||
// Only reflect the tombstones in the in-memory cache once they are durably committed.
|
||||
if (ok && tombstone)
|
||||
for (const auto& m : messages) tombstones_.insert(dedupHash(m.txid, m.payload_position));
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool ChatDatabase::isTombstoned(const ChatMessage& message) const
|
||||
{
|
||||
if (!key_ready_ || tombstones_.empty()) return false;
|
||||
return tombstones_.count(dedupHash(message.txid, message.payload_position)) > 0;
|
||||
}
|
||||
|
||||
void ChatDatabase::loadTombstones()
|
||||
{
|
||||
tombstones_.clear();
|
||||
if (!key_ready_ || !ensureOpen()) return;
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_, "DELETE FROM chat_messages WHERE wallet_tag = ?", -1, &stmt, nullptr)
|
||||
!= SQLITE_OK) {
|
||||
if (sqlite3_prepare_v2(db_, "SELECT dedup_hash FROM chat_deleted WHERE wallet_tag = ?",
|
||||
-1, &stmt, nullptr) != SQLITE_OK) {
|
||||
return;
|
||||
}
|
||||
sqlite3_bind_text(stmt, 1, wallet_tag_.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_step(stmt);
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
const auto* h = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 0));
|
||||
if (h) tombstones_.insert(h);
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
}
|
||||
|
||||
@@ -260,11 +329,18 @@ bool ChatDatabase::exec(const char* sql)
|
||||
|
||||
bool ChatDatabase::createSchema()
|
||||
{
|
||||
return exec("CREATE TABLE IF NOT EXISTS chat_messages ("
|
||||
if (!exec("CREATE TABLE IF NOT EXISTS chat_messages ("
|
||||
"wallet_tag TEXT NOT NULL, "
|
||||
"dedup_hash TEXT NOT NULL, "
|
||||
"nonce BLOB NOT NULL, "
|
||||
"payload BLOB NOT NULL, "
|
||||
"PRIMARY KEY (wallet_tag, dedup_hash))"))
|
||||
return false;
|
||||
// Tombstones for locally-deleted messages (dedup_hash only — the same keyed, non-revealing hash the
|
||||
// message rows use). A chain re-scan checks this so a deleted message never re-imports.
|
||||
return exec("CREATE TABLE IF NOT EXISTS chat_deleted ("
|
||||
"wallet_tag TEXT NOT NULL, "
|
||||
"dedup_hash TEXT NOT NULL, "
|
||||
"nonce BLOB NOT NULL, "
|
||||
"payload BLOB NOT NULL, "
|
||||
"PRIMARY KEY (wallet_tag, dedup_hash))");
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <string>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
struct sqlite3;
|
||||
@@ -54,8 +55,20 @@ public:
|
||||
|
||||
void clearWallet(); // delete the unlocked wallet's rows
|
||||
|
||||
// Per-conversation local delete. Removes the given messages' rows; when `tombstone` is true it also
|
||||
// records their (txid,position) dedup keys so a chain re-scan never re-imports them — this backs the
|
||||
// "delete, but a NEW message revives the thread" path. With `tombstone` false the rows are simply
|
||||
// removed (used by "delete & block", where a settings-level cid block suppresses re-import until the
|
||||
// user unblocks, at which point the history re-imports from chain). Atomic; no-op while locked.
|
||||
bool deleteMessages(const std::vector<ChatMessage>& messages, bool tombstone);
|
||||
|
||||
// True if this message's (txid,position) was locally deleted with a tombstone. Checked against an
|
||||
// in-memory cache loaded on unlock — O(1), no SQL. False while locked.
|
||||
bool isTombstoned(const ChatMessage& message) const;
|
||||
|
||||
private:
|
||||
bool ensureOpen();
|
||||
void loadTombstones(); // populate tombstones_ from chat_deleted for the unlocked wallet
|
||||
bool exec(const char* sql);
|
||||
bool createSchema();
|
||||
std::string dedupHash(const std::string& txid, std::size_t position) const;
|
||||
@@ -74,6 +87,7 @@ private:
|
||||
std::array<unsigned char, 32> key_{}; // AEAD storage key (seed-derived)
|
||||
std::string wallet_tag_; // seed-derived row partition (a keyed hash, hex)
|
||||
bool key_ready_ = false;
|
||||
std::unordered_set<std::string> tombstones_; // dedup_hash cache of locally-deleted messages
|
||||
};
|
||||
|
||||
} // namespace dragonx::chat
|
||||
|
||||
@@ -28,6 +28,10 @@ int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata
|
||||
std::int64_t fallbackTimestamp,
|
||||
std::vector<std::string>* newIncomingCids) {
|
||||
if (!has_identity_) return 0;
|
||||
// Persistence is attached but not unlocked (e.g. the DB failed to open with the seed): we can't
|
||||
// consult tombstones, so ingesting now would resurface locally-deleted messages into the store.
|
||||
// Skip until the DB is usable — chat is degraded anyway without its store.
|
||||
if (db_ && !db_->hasKey()) return 0;
|
||||
|
||||
const std::string myPubKey = chatIdentityPublicKeyHex(identity_);
|
||||
|
||||
@@ -62,6 +66,13 @@ int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata
|
||||
}
|
||||
message.payload_position = meta.payload_position;
|
||||
|
||||
// Suppress locally-removed conversations before the (relatively costly) decrypt. A blocked cid
|
||||
// is dropped outright (old + future messages) until unblocked; a tombstoned (txid,position) was
|
||||
// deleted with "revive on new message", so only that exact message is skipped — a new message
|
||||
// in the same conversation has a different txid and flows through normally.
|
||||
if (blocked_pred_ && blocked_pred_(message.conversation_id)) continue;
|
||||
if (db_ && db_->isTombstoned(message)) continue;
|
||||
|
||||
if (meta.type == HushChatHeaderType::ContactRequest) {
|
||||
message.kind = ChatMessageKind::ContactRequest;
|
||||
message.body = meta.payload_memo; // plaintext request text
|
||||
@@ -92,10 +103,28 @@ int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata
|
||||
void ChatService::loadFromDatabase() {
|
||||
if (!db_) return;
|
||||
for (const auto& message : db_->load()) {
|
||||
// Never surface a blocked conversation, even if a prior "delete & block" failed to remove its
|
||||
// rows (defense-in-depth): the ingest guard already drops live scans, this covers the reload path.
|
||||
if (blocked_pred_ && blocked_pred_(message.conversation_id)) continue;
|
||||
store_.append(message);
|
||||
}
|
||||
}
|
||||
|
||||
bool ChatService::deleteConversation(const std::string& conversationId, bool block) {
|
||||
// Delete the persisted rows FIRST and only mutate the in-memory view if that succeeds. Doing it the
|
||||
// other way round means a failed DB write (disk full / locked) would empty the store while the rows
|
||||
// survive — and on the next reload the conversation silently reappears with no tombstone.
|
||||
// Revive-on-new-message => tombstone the removed rows so a re-scan won't re-import them.
|
||||
// Block => remove the rows without a tombstone; the caller's blocked predicate suppresses re-import
|
||||
// until unblocked, at which point the conversation re-imports from chain.
|
||||
if (db_) {
|
||||
std::vector<ChatMessage> msgs = store_.conversation(conversationId); // snapshot (copy)
|
||||
if (!db_->deleteMessages(msgs, /*tombstone=*/!block)) return false;
|
||||
}
|
||||
store_.eraseConversation(conversationId);
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string ChatService::identityPublicKeyHex() const {
|
||||
if (!has_identity_) return {};
|
||||
return chatIdentityPublicKeyHex(identity_);
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
#include "chat_store.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
@@ -55,6 +56,19 @@ public:
|
||||
// the seed-derived key) into the in-memory store. No-op without an unlocked database.
|
||||
void loadFromDatabase();
|
||||
|
||||
// Set a predicate returning true for a BLOCKED conversation id. ingest() drops those messages
|
||||
// entirely (they are never stored or persisted) until the predicate stops returning true — this is
|
||||
// how "delete & block" suppresses old and future messages. Typically wired to Settings::isChatBlocked.
|
||||
void setBlockedPredicate(std::function<bool(const std::string&)> pred) { blocked_pred_ = std::move(pred); }
|
||||
|
||||
// Locally delete a conversation: remove its messages from the store and the database. When `block`
|
||||
// is false, the removed messages are tombstoned so a chain re-scan won't re-import them, but a
|
||||
// genuinely NEW message (new txid) revives the thread. When `block` is true, the rows are removed
|
||||
// WITHOUT a tombstone (the caller also records the cid as blocked via the predicate above); unblocking
|
||||
// later lets the conversation re-import from chain. Returns false (leaving the store untouched) if the
|
||||
// persisted rows couldn't be removed, so the caller can avoid a store/DB divergence.
|
||||
bool deleteConversation(const std::string& conversationId, bool block);
|
||||
|
||||
// --- Outgoing (compose) ---
|
||||
// My chat public key (hex), or "" without an identity — goes in an outgoing header's "p".
|
||||
std::string identityPublicKeyHex() const;
|
||||
@@ -90,6 +104,7 @@ private:
|
||||
bool has_identity_ = false;
|
||||
ChatStore store_;
|
||||
ChatDatabase* db_ = nullptr; // optional; not owned
|
||||
std::function<bool(const std::string&)> blocked_pred_; // true => cid is blocked (drop its messages)
|
||||
};
|
||||
|
||||
} // namespace dragonx::chat
|
||||
|
||||
@@ -13,6 +13,7 @@ std::string ChatStore::dedupKey(const ChatMessage& message) {
|
||||
bool ChatStore::append(const ChatMessage& message) {
|
||||
if (!seen_.insert(dedupKey(message)).second) return false;
|
||||
messages_.push_back(message);
|
||||
++revision_;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -38,12 +39,24 @@ const ChatMessage* ChatStore::updateDelivery(const std::string& txid, ChatDelive
|
||||
for (auto& message : messages_) {
|
||||
if (message.txid == txid) {
|
||||
message.delivery = delivery;
|
||||
++revision_;
|
||||
return &message;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
int ChatStore::countUnread(const std::function<bool(const std::string&)>& excluded,
|
||||
const std::function<std::int64_t(const std::string&)>& seenFor) const {
|
||||
int unread = 0;
|
||||
for (const auto& m : messages_) {
|
||||
if (m.direction != ChatDirection::Incoming) continue;
|
||||
if (excluded && excluded(m.conversation_id)) continue;
|
||||
if (m.timestamp > seenFor(m.conversation_id)) ++unread;
|
||||
}
|
||||
return unread;
|
||||
}
|
||||
|
||||
std::vector<std::string> ChatStore::conversationIds() const {
|
||||
std::vector<std::string> ids;
|
||||
std::unordered_set<std::string> seenIds;
|
||||
@@ -53,9 +66,27 @@ std::vector<std::string> ChatStore::conversationIds() const {
|
||||
return ids;
|
||||
}
|
||||
|
||||
std::vector<ChatMessage> ChatStore::eraseConversation(const std::string& conversationId) {
|
||||
std::vector<ChatMessage> removed;
|
||||
std::vector<ChatMessage> kept;
|
||||
kept.reserve(messages_.size());
|
||||
for (auto& m : messages_) {
|
||||
if (m.conversation_id == conversationId) {
|
||||
seen_.erase(dedupKey(m));
|
||||
removed.push_back(std::move(m));
|
||||
} else {
|
||||
kept.push_back(std::move(m));
|
||||
}
|
||||
}
|
||||
messages_.swap(kept);
|
||||
if (!removed.empty()) ++revision_;
|
||||
return removed;
|
||||
}
|
||||
|
||||
void ChatStore::clear() {
|
||||
messages_.clear();
|
||||
seen_.clear();
|
||||
++revision_;
|
||||
}
|
||||
|
||||
} // namespace dragonx::chat
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
#include "chat_message.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
@@ -38,15 +40,32 @@ public:
|
||||
return out;
|
||||
}
|
||||
|
||||
// Remove every message in a conversation from the in-memory view and return the removed messages
|
||||
// (so the caller can delete/tombstone their persisted rows). Re-appends are prevented by the ingest
|
||||
// guard (blocked-cid predicate / DB tombstone), not here.
|
||||
std::vector<ChatMessage> eraseConversation(const std::string& conversationId);
|
||||
|
||||
std::size_t size() const { return messages_.size(); }
|
||||
bool empty() const { return messages_.empty(); }
|
||||
void clear();
|
||||
|
||||
// Monotonic counter bumped on every mutation (append / updateDelivery change / eraseConversation /
|
||||
// clear). Callers memoize expensive per-frame reads (conversation-list build, unread count) against it
|
||||
// so they only rebuild when the store actually changed.
|
||||
std::uint64_t revision() const { return revision_; }
|
||||
|
||||
// Count unread incoming messages in a SINGLE pass over the store: an incoming message counts when its
|
||||
// cid is not `excluded` and its timestamp is newer than `seenFor(cid)`. Avoids the per-conversation
|
||||
// copy+sort that conversation() does — the count doesn't need ordering.
|
||||
int countUnread(const std::function<bool(const std::string&)>& excluded,
|
||||
const std::function<std::int64_t(const std::string&)>& seenFor) const;
|
||||
|
||||
private:
|
||||
static std::string dedupKey(const ChatMessage& message);
|
||||
|
||||
std::vector<ChatMessage> messages_;
|
||||
std::unordered_set<std::string> seen_;
|
||||
std::uint64_t revision_ = 0;
|
||||
};
|
||||
|
||||
} // namespace dragonx::chat
|
||||
|
||||
@@ -157,6 +157,13 @@ bool Settings::load(const std::string& path)
|
||||
for (const auto& c : j["hidden_chat_cids"])
|
||||
if (c.is_string()) hidden_chat_cids_.push_back(c.get<std::string>());
|
||||
}
|
||||
if (j.contains("blocked_chat_convs") && j["blocked_chat_convs"].is_array()) {
|
||||
blocked_chat_convs_.clear();
|
||||
for (const auto& c : j["blocked_chat_convs"])
|
||||
if (c.is_object() && c.contains("cid") && c["cid"].is_string())
|
||||
blocked_chat_convs_.push_back({c["cid"].get<std::string>(),
|
||||
c.value("name", std::string())});
|
||||
}
|
||||
// Chat-tab customization (re-clamped through the setters so hand-edited JSON stays in range).
|
||||
loadScalar(j, "chat_emoji_color", chat_emoji_color_);
|
||||
loadScalar(j, "chat_poll_rate_sec", chat_poll_rate_sec_); setChatPollRateSec(chat_poll_rate_sec_);
|
||||
@@ -461,6 +468,13 @@ bool Settings::save(const std::string& path)
|
||||
j["hidden_chat_cids"] = json::array();
|
||||
for (const auto& c : hidden_chat_cids_)
|
||||
j["hidden_chat_cids"].push_back(c);
|
||||
j["blocked_chat_convs"] = json::array();
|
||||
for (const auto& b : blocked_chat_convs_) {
|
||||
json o;
|
||||
o["cid"] = b.cid;
|
||||
o["name"] = b.name;
|
||||
j["blocked_chat_convs"].push_back(o);
|
||||
}
|
||||
j["chat_emoji_color"] = chat_emoji_color_;
|
||||
j["chat_poll_rate_sec"] = chat_poll_rate_sec_;
|
||||
j["chat_bubble_style"] = chat_bubble_style_;
|
||||
|
||||
@@ -144,6 +144,26 @@ public:
|
||||
hidden_chat_cids_.end());
|
||||
}
|
||||
|
||||
// Blocked chat conversations (by cid). Unlike hide (reversible, keeps messages), block DELETES the
|
||||
// local history AND suppresses every message for the cid — old and future — until you unblock, at
|
||||
// which point the conversation re-imports from the chain. The last-known peer name is kept so the
|
||||
// "Blocked" list can label the entry (its messages are gone from the local store).
|
||||
struct BlockedChatConv { std::string cid; std::string name; };
|
||||
bool isChatBlocked(const std::string& cid) const {
|
||||
for (const auto& b : blocked_chat_convs_) if (b.cid == cid) return true;
|
||||
return false;
|
||||
}
|
||||
void setChatBlocked(const std::string& cid, const std::string& name, bool blocked) {
|
||||
const bool already = isChatBlocked(cid);
|
||||
if (blocked && !already) blocked_chat_convs_.push_back({cid, name});
|
||||
else if (!blocked && already)
|
||||
blocked_chat_convs_.erase(
|
||||
std::remove_if(blocked_chat_convs_.begin(), blocked_chat_convs_.end(),
|
||||
[&](const BlockedChatConv& b) { return b.cid == cid; }),
|
||||
blocked_chat_convs_.end());
|
||||
}
|
||||
const std::vector<BlockedChatConv>& blockedChatConversations() const { return blocked_chat_convs_; }
|
||||
|
||||
// ── Chat-tab customization (chat settings modal + Settings → Chat & Contacts) ──────
|
||||
bool getChatEmojiColor() const { return chat_emoji_color_; }
|
||||
void setChatEmojiColor(bool v) { chat_emoji_color_ = v; }
|
||||
@@ -559,6 +579,7 @@ private:
|
||||
std::string chat_reply_zaddr_;
|
||||
std::vector<std::string> muted_chat_cids_; // muted chat conversations by cid (Q10)
|
||||
std::vector<std::string> hidden_chat_cids_; // hidden chat conversations by cid
|
||||
std::vector<BlockedChatConv> blocked_chat_convs_; // blocked chat conversations (cid + last-known name)
|
||||
// Chat-tab customization (chat settings modal + Settings → Chat & Contacts).
|
||||
bool chat_emoji_color_ = true; // true = color (needs FreeType; falls back to mono if absent), false = monochrome
|
||||
float chat_poll_rate_sec_ = 2.5f; // 0-conf chat fast-scan cadence (full node)
|
||||
|
||||
@@ -68,8 +68,9 @@ bool AddressBook::load()
|
||||
}
|
||||
|
||||
DEBUG_LOGF("Address book loaded: %zu entries\n", entries_.size());
|
||||
++revision_;
|
||||
return true;
|
||||
|
||||
|
||||
} catch (const std::exception& e) {
|
||||
DEBUG_LOGF("Error loading address book: %s\n", e.what());
|
||||
return false;
|
||||
@@ -121,6 +122,7 @@ bool AddressBook::addEntry(const AddressBookEntry& entry)
|
||||
}
|
||||
|
||||
entries_.push_back(entry);
|
||||
++revision_;
|
||||
return save();
|
||||
}
|
||||
|
||||
@@ -136,6 +138,7 @@ bool AddressBook::updateEntry(size_t index, const AddressBookEntry& entry)
|
||||
}
|
||||
|
||||
entries_[index] = entry;
|
||||
++revision_;
|
||||
return save();
|
||||
}
|
||||
|
||||
@@ -146,6 +149,7 @@ bool AddressBook::removeEntry(size_t index)
|
||||
}
|
||||
|
||||
entries_.erase(entries_.begin() + index);
|
||||
++revision_;
|
||||
return save();
|
||||
}
|
||||
|
||||
@@ -159,7 +163,7 @@ int AddressBook::reattachLegacyScopes(const std::string& scopeId)
|
||||
e.scope = scopeId;
|
||||
++rescoped;
|
||||
}
|
||||
if (rescoped > 0) save();
|
||||
if (rescoped > 0) { ++revision_; save(); }
|
||||
return rescoped;
|
||||
}
|
||||
|
||||
|
||||
+10
-1
@@ -4,6 +4,7 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
@@ -121,11 +122,18 @@ public:
|
||||
*/
|
||||
size_t size() const { return entries_.size(); }
|
||||
|
||||
/**
|
||||
* @brief Monotonic counter bumped on every content change (add/update/remove/load/sweep). Consumers
|
||||
* (e.g. the chat conversation-list memo) key their caches off this so an IN-PLACE edit — a rename or
|
||||
* address change that keeps size() constant — still invalidates them.
|
||||
*/
|
||||
std::uint64_t revision() const { return revision_; }
|
||||
|
||||
/**
|
||||
* @brief UI-sweep ONLY: replace the in-memory entries WITHOUT persisting to disk, so the sweep can
|
||||
* seed demo contacts and restore the real book without a disk write. Do not use outside the sweep.
|
||||
*/
|
||||
void sweepSetEntries(std::vector<AddressBookEntry> e) { entries_ = std::move(e); }
|
||||
void sweepSetEntries(std::vector<AddressBookEntry> e) { entries_ = std::move(e); ++revision_; }
|
||||
|
||||
/**
|
||||
* @brief Check if empty
|
||||
@@ -134,6 +142,7 @@ public:
|
||||
|
||||
private:
|
||||
std::vector<AddressBookEntry> entries_;
|
||||
std::uint64_t revision_ = 0;
|
||||
std::string file_path_;
|
||||
};
|
||||
|
||||
|
||||
@@ -1108,6 +1108,22 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectRe
|
||||
? &hushChatReceivedOutputs
|
||||
: nullptr;
|
||||
|
||||
// Index result.transactions by (txid, type) once so the two replace-loops below
|
||||
// can find-and-replace in O(1) instead of a nested linear scan over the full
|
||||
// (potentially thousands-large) tx list on every 'recent' refresh cycle. The map
|
||||
// holds the index of the FIRST occurrence of each key (preserving the linear
|
||||
// scan's break-on-first-match), and is kept in sync on every append so a later
|
||||
// entry in the same cycle still finds an earlier appended one — exactly as the
|
||||
// re-scanned vector did before.
|
||||
auto txKey = [](const TransactionInfo& tx) {
|
||||
return tx.txid + '\x1f' + tx.type;
|
||||
};
|
||||
std::unordered_map<std::string, std::size_t> byKey;
|
||||
byKey.reserve(result.transactions.size());
|
||||
for (std::size_t i = 0; i < result.transactions.size(); ++i) {
|
||||
byKey.emplace(txKey(result.transactions[i]), i); // keep first-occurrence index
|
||||
}
|
||||
|
||||
try {
|
||||
std::set<std::string> recentTxids;
|
||||
std::vector<TransactionInfo> recentTransactions;
|
||||
@@ -1115,15 +1131,13 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectRe
|
||||
appendTransparentTransactions(recentTransactions, recentTxids, transactions, snapshot.miningAddresses);
|
||||
|
||||
for (auto& recent : recentTransactions) {
|
||||
bool replaced = false;
|
||||
for (auto& existing : result.transactions) {
|
||||
if (existing.txid == recent.txid && existing.type == recent.type) {
|
||||
existing = recent;
|
||||
replaced = true;
|
||||
break;
|
||||
}
|
||||
auto it = byKey.find(txKey(recent));
|
||||
if (it != byKey.end()) {
|
||||
result.transactions[it->second] = recent;
|
||||
} else {
|
||||
byKey.emplace(txKey(recent), result.transactions.size());
|
||||
result.transactions.push_back(std::move(recent));
|
||||
}
|
||||
if (!replaced) result.transactions.push_back(std::move(recent));
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
DEBUG_LOGF("recent listtransactions error: %s\n", e.what());
|
||||
@@ -1149,15 +1163,13 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectRe
|
||||
snapshot.miningAddresses,
|
||||
hushChatReceivedOutputsPtr);
|
||||
for (auto& scanned : scannedTransactions) {
|
||||
bool replaced = false;
|
||||
for (auto& existing : result.transactions) {
|
||||
if (existing.txid == scanned.txid && existing.type == scanned.type) {
|
||||
existing = scanned;
|
||||
replaced = true;
|
||||
break;
|
||||
}
|
||||
auto it = byKey.find(txKey(scanned));
|
||||
if (it != byKey.end()) {
|
||||
result.transactions[it->second] = scanned;
|
||||
} else {
|
||||
byKey.emplace(txKey(scanned), result.transactions.size());
|
||||
result.transactions.push_back(std::move(scanned));
|
||||
}
|
||||
if (!replaced) result.transactions.push_back(std::move(scanned));
|
||||
}
|
||||
if (currentBlockHeight >= 0) result.shieldedScanHeights[address] = currentBlockHeight;
|
||||
++result.shieldedAddressesScanned;
|
||||
|
||||
+16
-10
@@ -679,15 +679,18 @@ inline bool RenderSidebar(NavPage& current, float sidebarWidth, float contentHei
|
||||
ImU32 textCol = selected ? Primary() : (pageNeedsUnlock ? OnSurfaceDisabled() : OnSurfaceMedium());
|
||||
|
||||
if (showLabels) {
|
||||
// Reserve room for a badge (if this item will draw one) so the
|
||||
// label centers in the space to the left of it instead of
|
||||
// running underneath the badge circle.
|
||||
bool itemHasBadge =
|
||||
(item.page == NavPage::History && status.unconfirmedTxCount > 0) ||
|
||||
(item.page == NavPage::Mining && status.miningActive) ||
|
||||
(item.page == NavPage::Chat && status.chatUnreadCount > 0);
|
||||
// The badge is a fixed top-right corner overlay, so it must NOT move
|
||||
// the icon+label — otherwise the text jumps sideways the moment a live
|
||||
// count toggles the badge on/off. Reserve clearance from whether the
|
||||
// page CAN show a badge (constant per item), never from the current
|
||||
// count, and keep the icon+label centered in the FULL button width so
|
||||
// the text position and size stay identical with or without a badge.
|
||||
bool itemBadgeCapable =
|
||||
item.page == NavPage::History ||
|
||||
item.page == NavPage::Mining ||
|
||||
item.page == NavPage::Chat;
|
||||
float badgeReserve = 0.0f;
|
||||
if (itemHasBadge) {
|
||||
if (itemBadgeCapable) {
|
||||
bool dotOnlyReserve = (item.page == NavPage::Mining);
|
||||
float badgeRReserve = dotOnlyReserve ? badgeRadiusDot : badgeRadiusNumber;
|
||||
float badgeInsetXReserve = sde("badge-inset-x", 6.0f);
|
||||
@@ -697,14 +700,17 @@ inline bool RenderSidebar(NavPage& current, float sidebarWidth, float contentHei
|
||||
ImFont* font = selected ? Type().subtitle2() : Type().body2();
|
||||
float lblFsz = ScaledFontSize(font);
|
||||
float btnW = indMax.x - indMin.x;
|
||||
float maxLabelW = btnW - iconS * 2.0f - iconLabelGap - Layout::spacingXs() * 2 - badgeReserve;
|
||||
// Clearance is symmetric (2x) because the group stays centered in the
|
||||
// full width: reserving on both sides keeps the label's right edge clear
|
||||
// of the right-side corner badge without shifting the center off-axis.
|
||||
float maxLabelW = btnW - iconS * 2.0f - iconLabelGap - Layout::spacingXs() * 2 - badgeReserve * 2.0f;
|
||||
ImVec2 labelSz = font->CalcTextSizeA(lblFsz, 1000.0f, 0.0f, NavLabel(item));
|
||||
if (labelSz.x > maxLabelW && maxLabelW > 0) {
|
||||
lblFsz *= maxLabelW / labelSz.x;
|
||||
labelSz = font->CalcTextSizeA(lblFsz, 1000.0f, 0.0f, NavLabel(item));
|
||||
}
|
||||
float totalW = iconS * 2.0f + iconLabelGap + labelSz.x;
|
||||
float btnCX = (indMin.x + indMax.x - badgeReserve) * 0.5f;
|
||||
float btnCX = (indMin.x + indMax.x) * 0.5f;
|
||||
float startX = btnCX - totalW * 0.5f;
|
||||
|
||||
DrawNavIcon(dl, item.page, startX + iconS, iconCY, iconS, textCol);
|
||||
|
||||
+215
-26
@@ -93,6 +93,10 @@ char s_new_zaddr[128] = "";
|
||||
char s_new_msg[256] = "";
|
||||
char s_search[80] = ""; // conversation-list filter (Q8)
|
||||
bool s_show_hidden = false; // when on, the list also shows hidden conversations (with an Unhide action)
|
||||
bool s_show_delete_confirm = false; // "Delete conversation?" confirm overlay (revive vs block)
|
||||
std::string s_delete_cid; // conversation targeted by the delete confirm
|
||||
std::string s_delete_name; // its peer name (for the confirm copy / block-list label)
|
||||
bool s_show_blocked = false; // blocked-conversations manager overlay (unblock)
|
||||
bool s_show_emoji_picker = false; // emoji picker overlay — fills the conversation-list pane while open
|
||||
char s_emoji_search[48] = ""; // emoji picker keyword filter
|
||||
|
||||
@@ -523,6 +527,16 @@ struct ConvSummary {
|
||||
bool hidden = false; // shown only while "Show hidden" is on
|
||||
};
|
||||
|
||||
// Per-frame memoization of the conversation-list build and the open-thread message list (both otherwise
|
||||
// rescan + copy + sort the whole chat history every frame). File-scope so ResetChatTab() can reset them
|
||||
// on a wallet switch; the hide/unhide/rename handlers reset s_convsKey directly to force a rebuild.
|
||||
std::vector<ConvSummary> s_convs;
|
||||
int s_convsHidden = 0;
|
||||
std::uint64_t s_convsKey = ~0ull;
|
||||
std::string s_threadCid;
|
||||
std::uint64_t s_threadRev = ~0ull;
|
||||
std::vector<chat::ChatMessage> s_threadMsgs;
|
||||
|
||||
// Centered, muted, wrapped hint for the empty states.
|
||||
void centeredHint(const char* text) {
|
||||
ImVec2 avail = ImGui::GetContentRegionAvail();
|
||||
@@ -725,32 +739,46 @@ void RenderChatTab(App* app)
|
||||
}
|
||||
|
||||
// Build conversation summaries (single scan per conversation), sorted by most-recent activity.
|
||||
std::vector<ConvSummary> convs;
|
||||
int hiddenCount = 0;
|
||||
for (const auto& cid : store.conversationIds()) {
|
||||
const bool hidden = app->settings() && app->settings()->isChatHidden(cid);
|
||||
if (hidden) ++hiddenCount;
|
||||
if (hidden && !s_show_hidden) continue; // filtered out unless "Show hidden" is on
|
||||
const auto messages = store.conversation(cid);
|
||||
if (messages.empty()) continue;
|
||||
ConvSummary c;
|
||||
c.cid = cid;
|
||||
c.hidden = hidden;
|
||||
c.count = static_cast<int>(messages.size());
|
||||
for (const auto& m : messages) { // pin to the EARLIEST (establishing) peer z-addr / key (B2 — the
|
||||
if (c.peerZaddr.empty() && !m.peer_zaddr.empty()) c.peerZaddr = m.peer_zaddr; // memo header rides
|
||||
if (c.peerPubKey.empty() && !m.peer_public_key_hex.empty()) c.peerPubKey = m.peer_public_key_hex; // outside the AEAD)
|
||||
// MEMOIZED: this previously rescanned + copied + sorted the ENTIRE chat history every frame. Rebuild
|
||||
// only when the store changed (revision), the show-hidden toggle flipped, or the contact list grew
|
||||
// (peerName resolution). Hide/unhide and rename don't move any of those, so those handlers force a
|
||||
// rebuild by resetting s_convsKey (delete/block already bump the store revision). s_convsKey is reset
|
||||
// in ResetChatTab on wallet switch.
|
||||
const std::uint64_t convsKey =
|
||||
store.revision() * 1000003ull
|
||||
+ static_cast<std::uint64_t>(s_show_hidden ? 1 : 0)
|
||||
+ (book.revision() << 20); // book.revision() catches in-place contact edits (rename) that keep size()
|
||||
if (convsKey != s_convsKey) {
|
||||
s_convs.clear();
|
||||
s_convsHidden = 0;
|
||||
for (const auto& cid : store.conversationIds()) {
|
||||
const bool hidden = app->settings() && app->settings()->isChatHidden(cid);
|
||||
if (hidden) ++s_convsHidden;
|
||||
if (hidden && !s_show_hidden) continue; // filtered out unless "Show hidden" is on
|
||||
const auto messages = store.conversation(cid);
|
||||
if (messages.empty()) continue;
|
||||
ConvSummary c;
|
||||
c.cid = cid;
|
||||
c.hidden = hidden;
|
||||
c.count = static_cast<int>(messages.size());
|
||||
for (const auto& m : messages) { // pin to the EARLIEST (establishing) peer z-addr / key (B2 — the
|
||||
if (c.peerZaddr.empty() && !m.peer_zaddr.empty()) c.peerZaddr = m.peer_zaddr; // memo header rides
|
||||
if (c.peerPubKey.empty() && !m.peer_public_key_hex.empty()) c.peerPubKey = m.peer_public_key_hex; // outside the AEAD)
|
||||
}
|
||||
const auto& last = messages.back();
|
||||
c.lastBody = last.body;
|
||||
c.lastTs = last.timestamp;
|
||||
const int idx = c.peerZaddr.empty() ? -1 : book.findByAddress(c.peerZaddr);
|
||||
c.peerName = (idx >= 0) ? book.entries()[idx].label
|
||||
: shorten(!c.peerZaddr.empty() ? c.peerZaddr : cid);
|
||||
s_convs.push_back(std::move(c));
|
||||
}
|
||||
const auto& last = messages.back();
|
||||
c.lastBody = last.body;
|
||||
c.lastTs = last.timestamp;
|
||||
const int idx = c.peerZaddr.empty() ? -1 : book.findByAddress(c.peerZaddr);
|
||||
c.peerName = (idx >= 0) ? book.entries()[idx].label
|
||||
: shorten(!c.peerZaddr.empty() ? c.peerZaddr : cid);
|
||||
convs.push_back(std::move(c));
|
||||
std::sort(s_convs.begin(), s_convs.end(),
|
||||
[](const ConvSummary& a, const ConvSummary& b) { return a.lastTs > b.lastTs; });
|
||||
s_convsKey = convsKey;
|
||||
}
|
||||
std::sort(convs.begin(), convs.end(),
|
||||
[](const ConvSummary& a, const ConvSummary& b) { return a.lastTs > b.lastTs; });
|
||||
std::vector<ConvSummary>& convs = s_convs;
|
||||
int hiddenCount = s_convsHidden;
|
||||
|
||||
// Keep the selection valid (only when there is something to select).
|
||||
if (!convs.empty() &&
|
||||
@@ -845,6 +873,15 @@ void RenderChatTab(App* app)
|
||||
ImGui::SetNextItemWidth(-FLT_MIN);
|
||||
ImGui::InputTextWithHint("##chatsearch", TR("chat_search"), s_search, sizeof(s_search));
|
||||
}
|
||||
// Blocked-conversations manager opener — only when at least one is blocked. Blocked convs have no
|
||||
// stored messages (deleted), so they can't appear in the list; this opens a small manager to unblock.
|
||||
const int blockedCount = app->settings() ? (int)app->settings()->blockedChatConversations().size() : 0;
|
||||
if (blockedCount > 0) {
|
||||
const std::string bl = std::string(TR("chat_blocked_manage")) + " (" + std::to_string(blockedCount) + ")";
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
|
||||
if (ImGui::SmallButton(bl.c_str())) s_show_blocked = true;
|
||||
ImGui::PopStyleColor();
|
||||
}
|
||||
const std::string search = s_search;
|
||||
ImGui::Separator();
|
||||
if (convs.empty()) {
|
||||
@@ -1018,7 +1055,7 @@ void RenderChatTab(App* app)
|
||||
// The toolbar's left edge is known up front (from the button count). A rename (edit) icon is
|
||||
// shown whenever there's an address to save the contact under; the settings "notch" gear is
|
||||
// always the rightmost icon.
|
||||
const int nBtns = 4 + (hasAddr ? 1 : 0);
|
||||
const int nBtns = 5 + (hasAddr ? 1 : 0); // export, mute, hide, delete, settings (+rename)
|
||||
const float toolbarLeft = rightX - (nBtns * ib + (nBtns - 1) * gap);
|
||||
|
||||
// Compact address + lock (or waiting-chip) metrics, reserved to the right of the name.
|
||||
@@ -1069,6 +1106,7 @@ void RenderChatTab(App* app)
|
||||
} else {
|
||||
Notifications::instance().error(TR("address_book_exists"));
|
||||
}
|
||||
s_convsKey = ~0ull; // peerName changed — force the conversation-list memo to rebuild
|
||||
}
|
||||
s_rename_cid.clear();
|
||||
} else if (cancel) {
|
||||
@@ -1170,6 +1208,21 @@ void RenderChatTab(App* app)
|
||||
s_selected_cid.clear();
|
||||
Notifications::instance().info(TR("chat_hidden_toast"));
|
||||
}
|
||||
s_convsKey = ~0ull; // hidden-state changed — force the conversation-list memo to rebuild
|
||||
}
|
||||
bx += ib + gap;
|
||||
}
|
||||
// Delete — clears this conversation's LOCAL history. Destructive (and offers a
|
||||
// "delete & block" variant), so it opens a confirm dialog rather than acting inline.
|
||||
{
|
||||
ImGui::SetCursorScreenPos(ImVec2(bx, by));
|
||||
material::IconButtonStyle a = base;
|
||||
a.tooltip = TR("chat_delete");
|
||||
a.hoverColor = material::Error();
|
||||
if (material::IconButton("##hdr_delete", ICON_MD_DELETE_OUTLINE, ifont, ImVec2(ib, ib), a)) {
|
||||
s_delete_cid = sel->cid;
|
||||
s_delete_name = sel->peerName;
|
||||
s_show_delete_confirm = true;
|
||||
}
|
||||
bx += ib + gap;
|
||||
}
|
||||
@@ -1278,7 +1331,15 @@ void RenderChatTab(App* app)
|
||||
const float groupGap = (compact ? 4.0f : 7.0f) * dp;
|
||||
const float msgGap = (compact ? 2.0f : 3.0f) * dp;
|
||||
const ImU32 accentBase = bubbleAccentColor(cs ? cs->getChatBubbleAccent() : 0);
|
||||
const auto messages = store.conversation(s_selected_cid);
|
||||
// MEMOIZED: store.conversation() linear-scans ALL messages across ALL conversations and
|
||||
// copies+sorts the match every call. Rebuild only when the open thread or the store changes,
|
||||
// not every frame while the thread is simply being read/scrolled.
|
||||
if (s_selected_cid != s_threadCid || store.revision() != s_threadRev) {
|
||||
s_threadMsgs = store.conversation(s_selected_cid);
|
||||
s_threadCid = s_selected_cid;
|
||||
s_threadRev = store.revision();
|
||||
}
|
||||
const auto& messages = s_threadMsgs;
|
||||
// Grouping + per-day separators (Tier 1). Same-sender messages within kGroupWindow share
|
||||
// one meta header and stack tightly; a date pill is drawn once per calendar day.
|
||||
const std::int64_t nowTs = static_cast<std::int64_t>(std::time(nullptr));
|
||||
@@ -1820,6 +1881,122 @@ void RenderChatTab(App* app)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Delete-conversation confirm (revive-on-new-message vs delete & block) ----
|
||||
if (s_show_delete_confirm) {
|
||||
const float dp = Layout::dpiScale();
|
||||
material::OverlayDialogSpec ov;
|
||||
ov.title = TR("chat_delete_title");
|
||||
ov.p_open = &s_show_delete_confirm; // X / backdrop closes it (no-op)
|
||||
ov.style = material::OverlayStyle::BlurFloat;
|
||||
ov.cardWidth = 500.0f; ov.idSuffix = "chatdelete";
|
||||
if (material::BeginOverlayDialog(ov)) {
|
||||
ImGui::PushTextWrapPos(0.0f);
|
||||
ImGui::TextUnformatted((std::string(TR("chat_delete_body_prefix")) + s_delete_name +
|
||||
TR("chat_delete_body_suffix")).c_str());
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
|
||||
ImGui::TextUnformatted(TR("chat_delete_revive_note"));
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
||||
ImGui::TextUnformatted(TR("chat_delete_local_note"));
|
||||
ImGui::PopStyleColor();
|
||||
ImGui::PopTextWrapPos();
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
|
||||
|
||||
auto doDelete = [&](bool block) {
|
||||
// Delete first — if the persisted rows can't be removed, change nothing else (no block,
|
||||
// no toast) so the store and DB can't diverge.
|
||||
if (!app->chatService().deleteConversation(s_delete_cid, block)) {
|
||||
Notifications::instance().error(TR("chat_delete_failed"));
|
||||
s_show_delete_confirm = false;
|
||||
s_delete_cid.clear(); s_delete_name.clear();
|
||||
return;
|
||||
}
|
||||
if (app->settings()) {
|
||||
if (block) app->settings()->setChatBlocked(s_delete_cid, s_delete_name, true);
|
||||
app->settings()->setChatHidden(s_delete_cid, false); // clear any prior hide flag
|
||||
app->settings()->save();
|
||||
}
|
||||
// Revive mode: forget the seen-watermark so a re-imported message badges as unread even if
|
||||
// its stamped time predates the deleted thread. Block mode keeps it, so an unblock-restored
|
||||
// history doesn't all re-badge.
|
||||
if (!block) app->forgetChatConversationSeen(s_delete_cid);
|
||||
if (s_selected_cid == s_delete_cid) s_selected_cid.clear();
|
||||
Notifications::instance().info(block ? TR("chat_blocked_toast") : TR("chat_deleted_toast"));
|
||||
s_show_delete_confirm = false;
|
||||
s_delete_cid.clear(); s_delete_name.clear();
|
||||
};
|
||||
|
||||
auto textW = [&](const char* t) {
|
||||
return ImGui::CalcTextSize(t).x + ImGui::GetStyle().FramePadding.x * 2.0f + 20.0f * dp;
|
||||
};
|
||||
const float gap2 = Layout::spacingSm();
|
||||
const float wDel = std::max(100.0f * dp, textW(TR("chat_delete_confirm")));
|
||||
const float wBlk = std::max(130.0f * dp, textW(TR("chat_delete_block")));
|
||||
const float wCan = std::max(90.0f * dp, textW(TR("chat_cancel")));
|
||||
material::BeginOverlayDialogFooter(wDel + wBlk + wCan + gap2 * 2.0f, /*drawSeparator=*/false);
|
||||
|
||||
ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Error(), 205)));
|
||||
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(material::Error()));
|
||||
ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Error(), 235)));
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(material::OnError()));
|
||||
const bool doDel = material::TactileButton(TR("chat_delete_confirm"), ImVec2(wDel, 0));
|
||||
ImGui::SameLine(0, gap2);
|
||||
const bool doBlk = material::TactileButton(TR("chat_delete_block"), ImVec2(wBlk, 0));
|
||||
ImGui::PopStyleColor(4);
|
||||
ImGui::SameLine(0, gap2);
|
||||
const bool doCancel = material::TactileButton(TR("chat_cancel"), ImVec2(wCan, 0));
|
||||
|
||||
if (doDel) doDelete(false);
|
||||
if (doBlk) doDelete(true);
|
||||
if (doCancel) { s_show_delete_confirm = false; s_delete_cid.clear(); s_delete_name.clear(); }
|
||||
|
||||
material::EndOverlayDialog();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Blocked-conversations manager (unblock) ----
|
||||
if (s_show_blocked) {
|
||||
const float dp = Layout::dpiScale();
|
||||
material::OverlayDialogSpec ov;
|
||||
ov.title = TR("chat_blocked_title");
|
||||
ov.p_open = &s_show_blocked;
|
||||
ov.style = material::OverlayStyle::BlurFloat;
|
||||
ov.cardWidth = 500.0f; ov.idSuffix = "chatblocked";
|
||||
if (material::BeginOverlayDialog(ov)) {
|
||||
ImGui::PushTextWrapPos(0.0f);
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
|
||||
ImGui::TextUnformatted(TR("chat_blocked_desc"));
|
||||
ImGui::PopStyleColor();
|
||||
ImGui::PopTextWrapPos();
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||
|
||||
if (app->settings()) {
|
||||
// Copy so unblocking (which mutates the settings vector) during iteration is safe.
|
||||
const auto blocked = app->settings()->blockedChatConversations();
|
||||
std::string unblockCid;
|
||||
for (const auto& b : blocked) {
|
||||
ImGui::PushID(b.cid.c_str());
|
||||
const std::string label = b.name.empty() ? shorten(b.cid, 10, 6) : b.name;
|
||||
const float bw = ImGui::CalcTextSize(TR("chat_unblock")).x +
|
||||
ImGui::GetStyle().FramePadding.x * 2.0f + 16.0f * dp;
|
||||
ImGui::AlignTextToFramePadding();
|
||||
ImGui::TextUnformatted(label.c_str());
|
||||
ImGui::SameLine();
|
||||
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + ImGui::GetContentRegionAvail().x - bw);
|
||||
if (material::TactileButton(TR("chat_unblock"), ImVec2(bw, 0))) unblockCid = b.cid;
|
||||
ImGui::PopID();
|
||||
}
|
||||
if (!unblockCid.empty()) {
|
||||
app->settings()->setChatBlocked(unblockCid, "", false);
|
||||
app->settings()->save();
|
||||
Notifications::instance().info(TR("chat_unblocked_toast")); // re-imports on the next chat scan
|
||||
if (app->settings()->blockedChatConversations().empty()) s_show_blocked = false;
|
||||
}
|
||||
}
|
||||
material::EndOverlayDialog();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Chat customization modal (opened by the header settings "notch") — house BlurFloat overlay ----
|
||||
if (s_show_chat_settings) {
|
||||
material::OverlayDialogSpec ov;
|
||||
@@ -1883,6 +2060,18 @@ void ResetChatTab()
|
||||
s_rename_focus = false;
|
||||
s_show_new_convo = false;
|
||||
s_show_chat_settings = false;
|
||||
s_show_delete_confirm = false;
|
||||
s_delete_cid.clear();
|
||||
s_delete_name.clear();
|
||||
s_show_blocked = false;
|
||||
// Drop the per-frame memoization caches so the next wallet doesn't briefly render the previous one's
|
||||
// conversations/thread (store.revision() is monotonic and would rebuild anyway, but be explicit).
|
||||
s_convs.clear();
|
||||
s_convsHidden = 0;
|
||||
s_convsKey = ~0ull;
|
||||
s_threadCid.clear();
|
||||
s_threadRev = ~0ull;
|
||||
s_threadMsgs.clear();
|
||||
}
|
||||
|
||||
void RenderChatSettingsControls(App* app, float contentWidth, bool drawCards)
|
||||
|
||||
@@ -37,18 +37,21 @@ ConsoleModel::DrainResult ConsoleModel::drain()
|
||||
lines_.pop_front();
|
||||
++result.popped;
|
||||
}
|
||||
++revision_; // deque changed — lets the view memoize its filter/layout passes
|
||||
return result;
|
||||
}
|
||||
|
||||
void ConsoleModel::clear()
|
||||
{
|
||||
lines_.clear();
|
||||
++revision_;
|
||||
}
|
||||
|
||||
bool ConsoleModel::toggleCollapsed(std::size_t i)
|
||||
{
|
||||
if (i >= lines_.size() || lines_[i].foldSpan <= 0) return false;
|
||||
lines_[i].collapsed = !lines_[i].collapsed;
|
||||
++revision_; // fold change alters which lines are visible
|
||||
return lines_[i].collapsed;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
#include "console_channel.h"
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
@@ -76,11 +77,16 @@ public:
|
||||
const ConsoleModelLine& operator[](std::size_t i) const { return lines_[i]; }
|
||||
const ConsoleModelLine& back() const { return lines_.back(); }
|
||||
|
||||
// Monotonic counter bumped whenever the visible deque changes (drain added/evicted lines, clear,
|
||||
// fold toggle). The view memoizes its per-frame filter + text-layout passes against this.
|
||||
std::uint64_t revision() const { return revision_; }
|
||||
|
||||
private:
|
||||
const std::size_t max_lines_;
|
||||
std::deque<ConsoleModelLine> lines_; // visible model — main thread only
|
||||
std::vector<ConsoleModelLine> pending_; // guarded by ingest_mutex_
|
||||
std::mutex ingest_mutex_;
|
||||
std::uint64_t revision_ = 0;
|
||||
};
|
||||
|
||||
} // namespace ui
|
||||
|
||||
@@ -820,11 +820,23 @@ void ConsoleTab::renderOutput()
|
||||
// segment records which bytes of the source text appear on that visual row, so
|
||||
// hit-testing and selection highlight can map screen positions to exact char offsets.
|
||||
float wrap_width = ClampConsoleWrapWidth(ImGui::GetContentRegionAvail().x, padX);
|
||||
ImFontConsoleMeasure measure(ImGui::GetFont(), ImGui::GetFontSize());
|
||||
layout_ = BuildConsoleLayout(
|
||||
static_cast<int>(visible_indices_.size()),
|
||||
[this](int vi) -> const std::string& { return model_[visible_indices_[vi]].text; },
|
||||
wrap_width, line_height, interLineGap, measure);
|
||||
// Memoize the text-shaping pass: rebuild only when the visible set, wrap width, line height, gap or
|
||||
// zoom changed. Otherwise this re-wrapped and re-measured every visible line (glyph-by-glyph) every
|
||||
// frame, even while idle/scrolled. layout_ is a member, so the cached geometry stays valid for
|
||||
// drawVisibleLines / screenToTextPos when we skip the rebuild.
|
||||
std::uint64_t layoutKey = vis_generation_ * 1000003ull;
|
||||
layoutKey = layoutKey * 131ull + static_cast<std::uint64_t>(wrap_width * 16.0f);
|
||||
layoutKey = layoutKey * 131ull + static_cast<std::uint64_t>(line_height * 16.0f);
|
||||
layoutKey = layoutKey * 131ull + static_cast<std::uint64_t>(interLineGap * 16.0f);
|
||||
layoutKey = layoutKey * 131ull + static_cast<std::uint64_t>(s_console_zoom * 1000.0f);
|
||||
if (layoutKey != layout_key_) {
|
||||
ImFontConsoleMeasure measure(ImGui::GetFont(), ImGui::GetFontSize());
|
||||
layout_ = BuildConsoleLayout(
|
||||
static_cast<int>(visible_indices_.size()),
|
||||
[this](int vi) -> const std::string& { return model_[visible_indices_[vi]].text; },
|
||||
wrap_width, line_height, interLineGap, measure);
|
||||
layout_key_ = layoutKey;
|
||||
}
|
||||
|
||||
// Mouse/keyboard interaction (wheel-up detach, selection drag, Ctrl+C/A). Raw IO bypasses
|
||||
// the child window's event consumption.
|
||||
@@ -891,6 +903,20 @@ void ConsoleTab::renderOutput()
|
||||
|
||||
void ConsoleTab::computeVisibleLines(bool& hasTextFilter, std::string& filterLower)
|
||||
{
|
||||
// Memoize: rebuild the visible set only when the model changed or the filter state changed.
|
||||
// Otherwise this scanned the entire (up to 10k-line) model with a per-line filter predicate every
|
||||
// frame. The out-params + filter_match_count_/folding_active_ are members that stay valid until the
|
||||
// key moves, so an early return leaves last frame's (still-correct) results in place.
|
||||
std::uint64_t visKey = model_.revision() * 1000003ull;
|
||||
for (const char* p = filter_text_; *p; ++p) visKey = visKey * 131ull + static_cast<unsigned char>(*p);
|
||||
visKey = visKey * 2ull + (s_daemon_messages_enabled ? 1u : 0u);
|
||||
visKey = visKey * 2ull + (s_errors_only_enabled ? 1u : 0u);
|
||||
visKey = visKey * 2ull + (s_rpc_trace_enabled ? 1u : 0u);
|
||||
visKey = visKey * 2ull + (s_app_messages_enabled ? 1u : 0u);
|
||||
if (visKey == vis_key_) return; // nothing that affects the visible set changed
|
||||
vis_key_ = visKey;
|
||||
++vis_generation_; // the layout pass keys off this
|
||||
|
||||
ConsoleOutputFilter outputFilter{filter_text_, s_daemon_messages_enabled,
|
||||
s_errors_only_enabled, s_rpc_trace_enabled,
|
||||
s_app_messages_enabled};
|
||||
@@ -2079,6 +2105,12 @@ void ConsoleTab::clear()
|
||||
// line indices) here to avoid an out-of-bounds crash. computeVisibleLines() rebuilds them next frame.
|
||||
visible_indices_.clear();
|
||||
selection_.clear();
|
||||
// Force both memoized passes to rebuild: renderOutput() runs later THIS frame against the now-empty
|
||||
// visible_indices_, and computeVisibleLines() recomputes next frame — without these resets the layout
|
||||
// memo would skip the rebuild and keep stale geometry for the just-emptied set.
|
||||
vis_key_ = ~0ull;
|
||||
layout_key_ = ~0ull;
|
||||
++vis_generation_;
|
||||
stop_confirm_pending_ = false; // a pending 'stop' confirmation is cancelled by clearing
|
||||
addLine(TR("console_cleared"), ConsoleChannel::Info);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
#include "../../rpc/rpc_client.h"
|
||||
#include "../../rpc/rpc_worker.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <deque>
|
||||
@@ -188,6 +189,14 @@ private:
|
||||
bool has_text_filter_ = false; // computed once per frame (before the toolbar draws it)
|
||||
std::string filter_lower_; // lowercased filter needle for match highlighting
|
||||
|
||||
// Memoization keys so the two expensive per-frame passes rebuild only on change (not every frame):
|
||||
// computeVisibleLines (filter scan over the whole model) is keyed on the model revision + filter
|
||||
// state; BuildConsoleLayout (glyph-by-glyph text shaping of every visible line) is keyed on the
|
||||
// resulting visible-set generation + wrap width + line height/zoom.
|
||||
std::uint64_t vis_key_ = ~0ull; // key of the last computeVisibleLines
|
||||
std::uint64_t vis_generation_ = 0; // bumped whenever visible_indices_ is rebuilt
|
||||
std::uint64_t layout_key_ = ~0ull; // key of the last BuildConsoleLayout
|
||||
|
||||
// Wrap layout for the visible lines (segments + per-line heights + cumulative Y),
|
||||
// recomputed each frame by the pure BuildConsoleLayout (console_text_layout.h) and
|
||||
// consumed by the renderer + hit-testing.
|
||||
|
||||
@@ -370,20 +370,25 @@ static void RenderRecentReceived(const AddressInfo& /* addr */,
|
||||
S.drawElement("tabs.balance", "recent-tx-icon-size").size * hs);
|
||||
ImU32 recvCol = Success();
|
||||
|
||||
// Collect matching transactions
|
||||
std::vector<const TransactionInfo*> recvs;
|
||||
for (const auto& tx : state.transactions) {
|
||||
if (tx.type != "receive" && tx.type != "mined") continue;
|
||||
recvs.push_back(&tx);
|
||||
}
|
||||
|
||||
// Grow the list to fill the dead space beneath the (fixed-height) receive card:
|
||||
// fit as many newest-first rows as the remaining region can show, instead of a
|
||||
// fixed 4. The child scrolls if the real history exceeds what fits, so nothing is
|
||||
// lost. A floor of 4 keeps the section substantial when the region is short.
|
||||
// Compute maxRows BEFORE the collect loop so the scan can stop early (below).
|
||||
float listH = std::max(rowH, ImGui::GetContentRegionAvail().y);
|
||||
size_t maxRows = std::max<size_t>(4, (size_t)std::floor(listH / rowH));
|
||||
if (recvs.size() > maxRows) recvs.resize(maxRows); // newest-first
|
||||
|
||||
// Collect matching transactions. state.transactions is newest-first, so the first
|
||||
// maxRows matches ARE exactly the rows we render — stop scanning once we have them
|
||||
// instead of filtering the entire history every frame (mirrors RenderSharedRecentTx
|
||||
// in balance_components.cpp).
|
||||
std::vector<const TransactionInfo*> recvs;
|
||||
recvs.reserve(maxRows);
|
||||
for (const auto& tx : state.transactions) {
|
||||
if (tx.type != "receive" && tx.type != "mined") continue;
|
||||
recvs.push_back(&tx);
|
||||
if (recvs.size() >= maxRows) break; // newest-first: prefix is all we show
|
||||
}
|
||||
|
||||
ImGui::BeginChild("##RecentReceivedRows", ImVec2(width, listH), false,
|
||||
ImGuiWindowFlags_NoBackground);
|
||||
|
||||
@@ -1120,20 +1120,25 @@ static void RenderRecentSends(const WalletState& state, float width, ImFont* cap
|
||||
S.drawElement("tabs.balance", "recent-tx-icon-size").size * hs);
|
||||
ImU32 sendCol = Error();
|
||||
|
||||
// Collect matching transactions
|
||||
std::vector<const TransactionInfo*> sends;
|
||||
for (const auto& tx : state.transactions) {
|
||||
if (tx.type != "send" && tx.type != "shield") continue;
|
||||
sends.push_back(&tx);
|
||||
}
|
||||
|
||||
// Grow the list to fill the dead space beneath the (fixed-height) compose card:
|
||||
// fit as many newest-first rows as the remaining region can show, instead of a
|
||||
// fixed 4. The child scrolls if the real history exceeds what fits, so nothing is
|
||||
// lost. A floor of 4 keeps the section substantial when the region is short.
|
||||
// Row budget is hoisted above the collect loop (it only needs the remaining region
|
||||
// height + row height, no per-row state) so the scan can early-exit.
|
||||
float listH = std::max(rowH, ImGui::GetContentRegionAvail().y);
|
||||
size_t maxRows = std::max<size_t>(4, (size_t)std::floor(listH / rowH));
|
||||
if (sends.size() > maxRows) sends.resize(maxRows); // newest-first
|
||||
|
||||
// Collect matching transactions. state.transactions is newest-first, so scan only a
|
||||
// bounded prefix and stop once maxRows matches are gathered (mirrors the early-exit in
|
||||
// balance_components.cpp:RenderSharedRecentTx) instead of filtering the whole history.
|
||||
std::vector<const TransactionInfo*> sends;
|
||||
sends.reserve(maxRows);
|
||||
for (const auto& tx : state.transactions) {
|
||||
if (tx.type != "send" && tx.type != "shield") continue;
|
||||
sends.push_back(&tx);
|
||||
if (sends.size() == maxRows) break; // newest-first: enough rows to fill the region
|
||||
}
|
||||
|
||||
ImGui::BeginChild("##RecentSendRows", ImVec2(width, listH), false,
|
||||
ImGuiWindowFlags_NoBackground);
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <ctime>
|
||||
#include <cmath>
|
||||
#include <unordered_map>
|
||||
@@ -186,53 +187,106 @@ void RenderTransactionsTab(App* app)
|
||||
// Summary Cards — Received | Sent | Mined
|
||||
// ================================================================
|
||||
{
|
||||
int recvCount = 0, sendCount = 0, minedCount = 0;
|
||||
double recvTotal = 0.0, sendTotal = 0.0, minedTotal = 0.0;
|
||||
|
||||
// Identify autoshield legs (same txid with a "send" leg and a "receive"-to-z leg):
|
||||
// that pair is a single internal shielding move — shown as one "Shield" row in the
|
||||
// list below — not income or spending. Counting both legs double-counts the amount
|
||||
// into BOTH the Sent and Received totals, so the cards disagree with the list.
|
||||
// Mirror the list's pairing logic and exclude both legs from the totals.
|
||||
std::unordered_map<std::string, std::vector<size_t>> summaryTxidMap;
|
||||
for (size_t i = 0; i < state.transactions.size(); i++)
|
||||
summaryTxidMap[state.transactions[i].txid].push_back(i);
|
||||
std::vector<bool> isShieldLeg(state.transactions.size(), false);
|
||||
for (const auto& kv : summaryTxidMap) {
|
||||
if (kv.second.size() < 2) continue;
|
||||
int send_i = -1, recv_i = -1;
|
||||
for (size_t si : kv.second) {
|
||||
const auto& stx = state.transactions[si];
|
||||
if (stx.type == "send" && send_i < 0) send_i = (int)si;
|
||||
else if (stx.type == "receive" && recv_i < 0 &&
|
||||
!stx.address.empty() && stx.address[0] == 'z') recv_i = (int)si;
|
||||
}
|
||||
if (send_i >= 0 && recv_i >= 0) {
|
||||
isShieldLeg[send_i] = true;
|
||||
isShieldLeg[recv_i] = true;
|
||||
// The list shows the merged shield under the "Sent" filter, so count it there too —
|
||||
// otherwise the Sent card reads 0 while the Sent list is non-empty. Use the shielded
|
||||
// (receive-leg) amount, which is what the merged row displays.
|
||||
sendCount++;
|
||||
sendTotal += std::abs(state.transactions[recv_i].amount);
|
||||
// Content fingerprint over state.transactions, shared by the summary-card totals (below) and
|
||||
// the display-list memoization (further down). This is a cheap, allocation-free FNV-1a hash
|
||||
// computed ONCE per frame and folds in every field the *totals* and the *displayed rows*
|
||||
// depend on: tx count, last update time, and per-tx txid (drives autoshield pairing),
|
||||
// amount (drives the totals — bit-exact), confirmations, timestamp, type, and address. It is
|
||||
// sort-independent (s_sort_mode is NOT folded here); the display key mixes s_sort_mode on top
|
||||
// so a sort change re-sorts the display cache without invalidating the totals (which don't
|
||||
// depend on order). NB vs. the old display key: this additionally folds full txid + amount +
|
||||
// full type/address (not just first char), which the summary totals require — the old key
|
||||
// omitted them, so it could not have gated the totals correctly.
|
||||
std::uint64_t contentKey;
|
||||
{
|
||||
std::uint64_t h = 1469598103934665603ULL; // FNV-1a offset basis
|
||||
auto mix = [&h](std::uint64_t v) { h = (h ^ v) * 1099511628211ULL; };
|
||||
auto mixBytes = [&mix](const std::string& s) {
|
||||
mix(s.size());
|
||||
for (unsigned char c : s) mix(static_cast<std::uint64_t>(c));
|
||||
};
|
||||
mix(state.transactions.size());
|
||||
mix(static_cast<std::uint64_t>(state.last_tx_update));
|
||||
for (const auto& t : state.transactions) {
|
||||
mixBytes(t.txid);
|
||||
std::uint64_t amtBits = 0;
|
||||
std::memcpy(&amtBits, &t.amount, sizeof(double)); // bit-exact fold of the double
|
||||
mix(amtBits);
|
||||
mix(static_cast<std::uint64_t>(t.confirmations));
|
||||
mix(static_cast<std::uint64_t>(t.timestamp));
|
||||
mixBytes(t.type);
|
||||
mixBytes(t.address);
|
||||
}
|
||||
contentKey = h;
|
||||
}
|
||||
|
||||
for (size_t i = 0; i < state.transactions.size(); i++) {
|
||||
if (isShieldLeg[i]) continue; // internal shielding move — not received or sent
|
||||
const auto& tx = state.transactions[i];
|
||||
if (tx.type == "receive") {
|
||||
recvCount++;
|
||||
recvTotal += std::abs(tx.amount);
|
||||
} else if (tx.type == "send") {
|
||||
sendCount++;
|
||||
sendTotal += std::abs(tx.amount);
|
||||
} else if (tx.type == "generate" || tx.type == "immature" || tx.type == "mined") {
|
||||
minedCount++;
|
||||
minedTotal += std::abs(tx.amount);
|
||||
// Summary-card totals: Received / Sent / Mined (counts + amounts). These are three static
|
||||
// numbers that only change when the tx list changes, so memoize them behind contentKey and
|
||||
// recompute only when it moves. File-scope-style function statics persist across wallet
|
||||
// switches, but a switch/refresh mutates state.transactions (count, txids, amounts …), which
|
||||
// bumps contentKey, so the sentinel initial key + change detection covers that for free.
|
||||
static int s_recvCount = 0, s_sendCount = 0, s_minedCount = 0;
|
||||
static double s_recvTotal = 0.0, s_sendTotal = 0.0, s_minedTotal = 0.0;
|
||||
static std::uint64_t s_summaryKey = 0; // sentinel; first frame always recomputes
|
||||
|
||||
if (contentKey != s_summaryKey) {
|
||||
int recvCount = 0, sendCount = 0, minedCount = 0;
|
||||
double recvTotal = 0.0, sendTotal = 0.0, minedTotal = 0.0;
|
||||
|
||||
// Identify autoshield legs (same txid with a "send" leg and a "receive"-to-z leg):
|
||||
// that pair is a single internal shielding move — shown as one "Shield" row in the
|
||||
// list below — not income or spending. Counting both legs double-counts the amount
|
||||
// into BOTH the Sent and Received totals, so the cards disagree with the list.
|
||||
// Mirror the list's pairing logic and exclude both legs from the totals.
|
||||
std::unordered_map<std::string, std::vector<size_t>> summaryTxidMap;
|
||||
for (size_t i = 0; i < state.transactions.size(); i++)
|
||||
summaryTxidMap[state.transactions[i].txid].push_back(i);
|
||||
std::vector<bool> isShieldLeg(state.transactions.size(), false);
|
||||
for (const auto& kv : summaryTxidMap) {
|
||||
if (kv.second.size() < 2) continue;
|
||||
int send_i = -1, recv_i = -1;
|
||||
for (size_t si : kv.second) {
|
||||
const auto& stx = state.transactions[si];
|
||||
if (stx.type == "send" && send_i < 0) send_i = (int)si;
|
||||
else if (stx.type == "receive" && recv_i < 0 &&
|
||||
!stx.address.empty() && stx.address[0] == 'z') recv_i = (int)si;
|
||||
}
|
||||
if (send_i >= 0 && recv_i >= 0) {
|
||||
isShieldLeg[send_i] = true;
|
||||
isShieldLeg[recv_i] = true;
|
||||
// The list shows the merged shield under the "Sent" filter, so count it there too —
|
||||
// otherwise the Sent card reads 0 while the Sent list is non-empty. Use the shielded
|
||||
// (receive-leg) amount, which is what the merged row displays.
|
||||
sendCount++;
|
||||
sendTotal += std::abs(state.transactions[recv_i].amount);
|
||||
}
|
||||
}
|
||||
|
||||
for (size_t i = 0; i < state.transactions.size(); i++) {
|
||||
if (isShieldLeg[i]) continue; // internal shielding move — not received or sent
|
||||
const auto& tx = state.transactions[i];
|
||||
if (tx.type == "receive") {
|
||||
recvCount++;
|
||||
recvTotal += std::abs(tx.amount);
|
||||
} else if (tx.type == "send") {
|
||||
sendCount++;
|
||||
sendTotal += std::abs(tx.amount);
|
||||
} else if (tx.type == "generate" || tx.type == "immature" || tx.type == "mined") {
|
||||
minedCount++;
|
||||
minedTotal += std::abs(tx.amount);
|
||||
}
|
||||
}
|
||||
|
||||
s_recvCount = recvCount; s_recvTotal = recvTotal;
|
||||
s_sendCount = sendCount; s_sendTotal = sendTotal;
|
||||
s_minedCount = minedCount; s_minedTotal = minedTotal;
|
||||
s_summaryKey = contentKey;
|
||||
}
|
||||
|
||||
// Render from the cached totals (identical values — only the recomputation is skipped).
|
||||
const int recvCount = s_recvCount, sendCount = s_sendCount, minedCount = s_minedCount;
|
||||
const double recvTotal = s_recvTotal, sendTotal = s_sendTotal, minedTotal = s_minedTotal;
|
||||
|
||||
float availWidth = ImGui::GetContentRegionAvail().x;
|
||||
float cardGap = cGap;
|
||||
float cardW = (availWidth - 2 * cardGap) / 3.0f;
|
||||
@@ -346,31 +400,24 @@ void RenderTransactionsTab(App* app)
|
||||
//
|
||||
// This merge + sort is O(N log N) with several heap allocations, so it is MEMOIZED: it only
|
||||
// rebuilds when the underlying transactions actually change, not every frame. The cache key
|
||||
// is a cheap, allocation-free FNV-1a fingerprint over the fields that affect the displayed
|
||||
// rows (count, last update time, and each tx's confirmations / timestamp / type+address
|
||||
// first char). A new block bumps every confirmation, so the key changes and we rebuild;
|
||||
// between changes (the common case while the user reads/scrolls) we reuse the cache. The
|
||||
// result is already sorted newest-first by the refresh service, but we re-sort here to apply
|
||||
// the "pending first" ordering — also folded into the memoized build.
|
||||
// reuses the shared per-frame contentKey computed above (a cheap, allocation-free FNV-1a
|
||||
// fingerprint over count / last update time / every tx's txid+amount+confirmations+timestamp+
|
||||
// type+address) and mixes in s_sort_mode on top, so the display cache also rebuilds on a sort
|
||||
// change. contentKey already folds in more than the display list strictly needs (amount,
|
||||
// full txid/type/address) — a superset, so any change that would have flipped the old
|
||||
// per-first-char key still flips this one. A new block bumps every confirmation, so the key
|
||||
// changes and we rebuild; between changes (the common case while the user reads/scrolls) we
|
||||
// reuse the cache. The result is already sorted newest-first by the refresh service, but we
|
||||
// re-sort here to apply the "pending first" ordering — also folded into the memoized build.
|
||||
static std::vector<DisplayTx> s_display_cache;
|
||||
static std::uint64_t s_display_cache_key = 0;
|
||||
static bool s_display_cache_valid = false;
|
||||
|
||||
std::uint64_t displayKey;
|
||||
{
|
||||
std::uint64_t h = 1469598103934665603ULL; // FNV-1a offset basis
|
||||
auto mix = [&h](std::uint64_t v) { h = (h ^ v) * 1099511628211ULL; };
|
||||
mix(state.transactions.size());
|
||||
mix(static_cast<std::uint64_t>(state.last_tx_update));
|
||||
mix(static_cast<std::uint64_t>(s_sort_mode)); // re-sort the cache when the mode changes
|
||||
for (const auto& t : state.transactions) {
|
||||
mix(static_cast<std::uint64_t>(t.confirmations));
|
||||
mix(static_cast<std::uint64_t>(t.timestamp));
|
||||
mix(t.type.empty() ? 0u : static_cast<unsigned char>(t.type[0]));
|
||||
mix(t.address.empty() ? 0u : static_cast<unsigned char>(t.address[0]));
|
||||
}
|
||||
displayKey = h;
|
||||
}
|
||||
// Derive the display key from the shared contentKey (avoids a second full pass over the
|
||||
// transactions) plus the sort mode — the only display-affecting input contentKey omits.
|
||||
std::uint64_t displayKey =
|
||||
(contentKey ^ (static_cast<std::uint64_t>(s_sort_mode) + 0x9E3779B97F4A7C15ULL))
|
||||
* 1099511628211ULL;
|
||||
|
||||
if (!s_display_cache_valid || displayKey != s_display_cache_key) {
|
||||
s_display_cache.clear();
|
||||
|
||||
@@ -259,6 +259,23 @@ void I18n::loadBuiltinEnglish()
|
||||
strings_["chat_emoji_search"] = "Search emoji";
|
||||
strings_["chat_hide_hidden"] = "Hide hidden";
|
||||
strings_["chat_hidden_toast"] = "Conversation hidden — a new message brings it back";
|
||||
// Delete conversation (local cache) — revive-on-new-message vs delete & block
|
||||
strings_["chat_delete"] = "Delete conversation";
|
||||
strings_["chat_delete_title"] = "Delete conversation?";
|
||||
strings_["chat_delete_body_prefix"] = "Delete your local copy of the conversation with ";
|
||||
strings_["chat_delete_body_suffix"] = "?";
|
||||
strings_["chat_delete_revive_note"] = "\"Delete\" clears the history on this device. If they message you again, the conversation comes back.";
|
||||
strings_["chat_delete_local_note"] = "This only affects this device — the messages stay on the blockchain and the other person keeps their copy.";
|
||||
strings_["chat_delete_confirm"] = "Delete";
|
||||
strings_["chat_delete_block"] = "Delete & block";
|
||||
strings_["chat_deleted_toast"] = "Conversation deleted";
|
||||
strings_["chat_delete_failed"] = "Couldn't delete the conversation — nothing was changed.";
|
||||
strings_["chat_blocked_toast"] = "Conversation deleted & blocked";
|
||||
strings_["chat_blocked_manage"] = "Blocked";
|
||||
strings_["chat_blocked_title"] = "Blocked conversations";
|
||||
strings_["chat_blocked_desc"] = "Blocked conversations are removed and their messages are dropped — old and new — until you unblock. Unblocking re-imports the conversation from the chain.";
|
||||
strings_["chat_unblock"] = "Unblock";
|
||||
strings_["chat_unblocked_toast"] = "Conversation unblocked";
|
||||
strings_["chat_pick_contact"] = "Choose from contacts\xE2\x80\xA6";
|
||||
strings_["chat_no_z_contacts"] = "No shielded-address contacts yet";
|
||||
strings_["chat_copy_address_tip"] = "Click to copy address";
|
||||
|
||||
@@ -7006,6 +7006,97 @@ void testHushChatDatabase()
|
||||
scrub(dbPath); scrub(dbPath2);
|
||||
}
|
||||
|
||||
// Per-conversation local delete: (1) "delete, revive on new message" tombstones the removed messages so
|
||||
// a chain re-scan can't re-import them — but a NEW txid in the same conversation flows through; the
|
||||
// tombstone survives a DB reload. (2) "delete & block" removes the rows WITHOUT a tombstone and relies on
|
||||
// a blocked-cid predicate to drop messages while blocked; unblocking re-imports the conversation.
|
||||
void testHushChatDeleteConversation()
|
||||
{
|
||||
using namespace dragonx::chat;
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
const std::string dbPath = (fs::temp_directory_path() / "drgx_chat_del_test.sqlite").string();
|
||||
auto scrub = [](const std::string& p) { fs::remove(p); fs::remove(p + "-wal"); fs::remove(p + "-shm"); };
|
||||
scrub(dbPath);
|
||||
|
||||
ChatKeyPair alice, bob;
|
||||
ChatIdentityResult ra = deriveChatIdentityFromSecret("del-alice", alice, true);
|
||||
ChatIdentityResult rb = deriveChatIdentityFromSecret("del-bob", bob, true);
|
||||
|
||||
// Build one incoming (alice->bob) metadata entry. encryptOutgoing produces a fresh ciphertext each
|
||||
// call, but dedup/tombstone key off (txid, position) only, so re-using the same txids re-scans them.
|
||||
auto mkMeta = [&](const std::string& txid, const std::string& cid, const std::string& body) {
|
||||
std::string e, ct;
|
||||
EXPECT_TRUE(encryptOutgoing(alice, rb.public_key_hex, body, e, ct) == ChatCryptoStatus::Ok);
|
||||
HushChatTransactionMetadata m;
|
||||
m.txid = txid; m.type = HushChatHeaderType::Message; m.conversation_id = cid;
|
||||
m.reply_zaddr = "zs-alice"; m.sender_public_key_hex = ra.public_key_hex;
|
||||
m.secretstream_header_hex = e; m.payload_memo = ct; m.payload_position = 1;
|
||||
return m;
|
||||
};
|
||||
std::vector<HushChatTransactionMetadata> convX{ mkMeta("dx1", "conv-x", "x-one"),
|
||||
mkMeta("dx2", "conv-x", "x-two") };
|
||||
std::vector<HushChatTransactionMetadata> convY{ mkMeta("dy1", "conv-y", "y-one") };
|
||||
|
||||
// (1) Delete with revive-on-new-message.
|
||||
{
|
||||
ChatDatabase db(dbPath);
|
||||
EXPECT_TRUE(db.unlockWithSecret("del-seed"));
|
||||
ChatService svc; svc.setIdentity(bob); svc.setPersistence(&db);
|
||||
EXPECT_EQ(svc.ingest(convX, {}, 100), 2);
|
||||
EXPECT_EQ(svc.ingest(convY, {}, 100), 1);
|
||||
EXPECT_EQ((int)svc.store().size(), 3);
|
||||
|
||||
EXPECT_TRUE(svc.deleteConversation("conv-x", /*block=*/false));
|
||||
EXPECT_EQ((int)svc.store().conversation("conv-x").size(), 0);
|
||||
EXPECT_EQ((int)svc.store().conversation("conv-y").size(), 1); // untouched
|
||||
|
||||
EXPECT_EQ(svc.ingest(convX, {}, 100), 0); // re-scan: tombstoned, not re-imported
|
||||
EXPECT_EQ((int)svc.store().conversation("conv-x").size(), 0);
|
||||
|
||||
std::vector<HushChatTransactionMetadata> convXnew{ mkMeta("dx3", "conv-x", "x-three") };
|
||||
EXPECT_EQ(svc.ingest(convXnew, {}, 200), 1); // NEW txid revives the thread
|
||||
EXPECT_EQ((int)svc.store().conversation("conv-x").size(), 1);
|
||||
EXPECT_EQ(svc.store().conversation("conv-x")[0].body, std::string("x-three"));
|
||||
}
|
||||
|
||||
// (2) Tombstone survives a DB reload into a fresh service.
|
||||
{
|
||||
ChatDatabase db(dbPath);
|
||||
EXPECT_TRUE(db.unlockWithSecret("del-seed"));
|
||||
ChatService svc; svc.setIdentity(bob); svc.setPersistence(&db);
|
||||
svc.loadFromDatabase();
|
||||
EXPECT_EQ((int)svc.store().conversation("conv-x").size(), 1); // only the revived dx3 persisted
|
||||
EXPECT_EQ(svc.store().conversation("conv-x")[0].body, std::string("x-three"));
|
||||
EXPECT_EQ(svc.ingest(convX, {}, 100), 0); // dx1/dx2 still tombstoned after reload
|
||||
EXPECT_EQ((int)svc.store().conversation("conv-x").size(), 1);
|
||||
}
|
||||
|
||||
// (3) Delete & block, then unblock.
|
||||
{
|
||||
ChatDatabase db(dbPath);
|
||||
EXPECT_TRUE(db.unlockWithSecret("del-seed"));
|
||||
ChatService svc; svc.setIdentity(bob); svc.setPersistence(&db);
|
||||
svc.loadFromDatabase();
|
||||
bool blocked = false;
|
||||
svc.setBlockedPredicate([&](const std::string& cid) { return blocked && cid == "conv-y"; });
|
||||
|
||||
EXPECT_EQ((int)svc.store().conversation("conv-y").size(), 1);
|
||||
blocked = true;
|
||||
EXPECT_TRUE(svc.deleteConversation("conv-y", /*block=*/true));
|
||||
EXPECT_EQ((int)svc.store().conversation("conv-y").size(), 0);
|
||||
EXPECT_EQ(svc.ingest(convY, {}, 100), 0); // suppressed by the predicate (no tombstone)
|
||||
EXPECT_EQ((int)svc.store().conversation("conv-y").size(), 0);
|
||||
|
||||
blocked = false; // unblock
|
||||
EXPECT_EQ(svc.ingest(convY, {}, 100), 1); // re-imported from chain
|
||||
EXPECT_EQ((int)svc.store().conversation("conv-y").size(), 1);
|
||||
EXPECT_EQ(svc.store().conversation("conv-y")[0].body, std::string("y-one"));
|
||||
}
|
||||
|
||||
scrub(dbPath);
|
||||
}
|
||||
|
||||
// Phase 4: outgoing memo construction round-trips through the receive parser + decrypt, and
|
||||
// ChatService compose/recordOutgoing echoes into the store.
|
||||
void testHushChatOutgoing()
|
||||
@@ -7456,6 +7547,7 @@ int main()
|
||||
testHushChatReceivePath();
|
||||
testHushChatService();
|
||||
testHushChatDatabase();
|
||||
testHushChatDeleteConversation();
|
||||
testHushChatOutgoing();
|
||||
testHushChatTransport();
|
||||
testHushChatShuffledReceive();
|
||||
|
||||
Reference in New Issue
Block a user