feat(history): badge chat transactions + add a Chat filter

Transactions that carried a chat message (sent or received, messages + contact
requests) now show a "Message" badge in the History tab, and a new "Chat" option
in the type-filter combo shows only those transactions.

Detection reuses what the chat store already tracks: ChatStore gains an inline
chatTxids() returning the set of on-chain txids that carried a chat message; the
tab builds it once per frame (cheap — O(chat messages)) and tests txid membership
for the badge and the filter. Empty when chat is disabled or the wallet has no
chat identity. Both variants populate the chat store before this tab renders, so
the same code serves full-node and lite with no separate handling.

The "Message" badge reuses the stacked top-pill slot (chat txs are send/receive,
not the autoshield "shield" type, so it and the "Shielded" badge are mutually
exclusive; chat takes precedence) in a distinct Secondary() colour. Guards the
summary-card accent idx_map so the new filter value (4) can't index it OOB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-21 01:43:55 -05:00
parent 4c43f2e082
commit 987d9b93c7
11 changed files with 62 additions and 19 deletions

View File

@@ -26,6 +26,7 @@
#include <ctime>
#include <cmath>
#include <unordered_map>
#include <unordered_set>
#include <vector>
namespace dragonx {
@@ -141,6 +142,12 @@ void RenderTransactionsTab(App* app)
const auto addrLabel = S.label("tabs.transactions", "address-label");
const auto& state = app->state();
// Txids that carried a chat message (sent or received) — drives the "Message" badge and the Chat
// filter below. Rebuilt each frame from the in-memory chat store (cheap: O(chat messages), which
// is small); empty when chat is disabled or the wallet has no chat identity. Both variants populate
// the chat store before this tab renders, so the same lookup serves full-node and lite.
const std::unordered_set<std::string> chatTxids = app->chatService().store().chatTxids();
// Responsive scale factors (recomputed every frame)
ImVec2 contentAvail = ImGui::GetContentRegionAvail();
const float hs = Layout::hScale(contentAvail.x);
@@ -250,8 +257,9 @@ void RenderTransactionsTab(App* app)
innerPad, iconSz, glassSpec, ovFont, capFont, body2, "mined", goldCol, "mined_upper",
minedCount, minedTotal, "+", 3, type_filter);
// Selected card accent
if (type_filter > 0) {
// Selected card accent (only the three summary cards map to a card position — the Chat filter
// (4) has no card, so guard the idx_map lookup to 1..3).
if (type_filter > 0 && type_filter <= 3) {
int idx_map[] = {-1, 1, 0, 2};
int idx = idx_map[type_filter];
float xOff = idx * (cardW + cardGap);
@@ -281,7 +289,8 @@ void RenderTransactionsTab(App* app)
ImGui::SameLine(0, filterGap);
float comboW = std::max(80.0f, ((filterCombo.width > 0) ? filterCombo.width : 120.0f) * hs);
ImGui::SetNextItemWidth(comboW);
const char* types[] = { TR("all_filter"), TR("sent_filter"), TR("received_filter"), TR("mined_filter") };
const char* types[] = { TR("all_filter"), TR("sent_filter"), TR("received_filter"),
TR("mined_filter"), TR("chat_filter") };
ImGui::Combo("##TxType", &type_filter, types, IM_ARRAYSIZE(types));
// Sort selector
@@ -476,6 +485,7 @@ void RenderTransactionsTab(App* app)
if (type_filter == 1 && dtx.display_type != "send" && dtx.display_type != "shield") continue;
if (type_filter == 2 && dtx.display_type != "receive") continue;
if (type_filter == 3 && dtx.display_type != "generate" && dtx.display_type != "immature" && dtx.display_type != "mined") continue;
if (type_filter == 4 && chatTxids.count(dtx.txid) == 0) continue; // chat-only
}
if (!search_str.empty()) {
if (!containsIgnoreCase(dtx.address, search_str) &&
@@ -657,6 +667,7 @@ void RenderTransactionsTab(App* app)
// Determine type info
bool shieldedDisplay = tx.display_type == "shield";
const bool isChatTx = chatTxids.count(tx.txid) > 0; // carried a chat message
ImU32 iconCol;
const char* typeStr;
if (shieldedDisplay) {
@@ -749,12 +760,17 @@ void RenderTransactionsTab(App* app)
}
// Position status badge in the middle-right area
ImVec2 sSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, statusStr);
const char* shieldedStr = TR("shielded_type");
ImVec2 shieldSz = shieldedDisplay
? capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, shieldedStr)
// Optional "top" badge stacked above the status pill: "Message" for a chat tx or
// "Shielded" for an autoshield. Chat txs are send/receive (not the "shield" type),
// so the two are mutually exclusive; chat takes precedence.
const bool showTopBadge = isChatTx || shieldedDisplay;
const char* topBadgeStr = isChatTx ? TR("tx_chat_badge") : TR("shielded_type");
const ImU32 topBadgeCol = isChatTx ? Secondary() : Primary();
ImVec2 topSz = showTopBadge
? capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, topBadgeStr)
: ImVec2(0, 0);
float shieldPillW = shieldSz.x + Layout::spacingSm() * 2.0f;
float stackW = shieldedDisplay ? std::max(sSz.x, shieldPillW) : sSz.x;
float topPillW = topSz.x + Layout::spacingSm() * 2.0f;
float stackW = showTopBadge ? std::max(sSz.x, topPillW) : sSz.x;
float statusX = amtX - stackW - Layout::spacingXxl();
float minStatusX = cx + innerW * 0.25f; // don't overlap address
if (statusX < minStatusX) statusX = minStatusX;
@@ -763,18 +779,17 @@ void RenderTransactionsTab(App* app)
// row background on every skin (faint alpha-30 fills used to vanish on dark-red /
// near-white / gradient skins). Fill 48, border 90 of the state color's RGB.
const float pillRound = schema::UI().drawElement("tabs.transactions", "status-pill-rounding").size;
if (shieldedDisplay) {
float shieldX = statusX + (stackW - shieldSz.x) * 0.5f;
ImU32 shieldCol = Primary();
ImVec2 shieldPillMin(shieldX - Layout::spacingSm(), cy - 1.0f);
ImVec2 shieldPillMax(shieldX + shieldSz.x + Layout::spacingSm(),
shieldPillMin.y + capFont->LegacySize + Layout::spacingXs());
dl->AddRectFilled(shieldPillMin, shieldPillMax,
(shieldCol & 0x00FFFFFFu) | (static_cast<ImU32>(48) << 24), pillRound);
dl->AddRect(shieldPillMin, shieldPillMax,
(shieldCol & 0x00FFFFFFu) | (static_cast<ImU32>(90) << 24), pillRound, 0, 1.0f);
if (showTopBadge) {
float topX = statusX + (stackW - topSz.x) * 0.5f;
ImVec2 topPillMin(topX - Layout::spacingSm(), cy - 1.0f);
ImVec2 topPillMax(topX + topSz.x + Layout::spacingSm(),
topPillMin.y + capFont->LegacySize + Layout::spacingXs());
dl->AddRectFilled(topPillMin, topPillMax,
(topBadgeCol & 0x00FFFFFFu) | (static_cast<ImU32>(48) << 24), pillRound);
dl->AddRect(topPillMin, topPillMax,
(topBadgeCol & 0x00FFFFFFu) | (static_cast<ImU32>(90) << 24), pillRound, 0, 1.0f);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(shieldX, cy), shieldCol, shieldedStr);
ImVec2(topX, cy), topBadgeCol, topBadgeStr);
}
// Background pill
ImVec2 pillMin(statusTextX - Layout::spacingSm(), cy + body2->LegacySize + 1);