ObsidianDragon - DragonX ImGui Wallet
Full-node GUI wallet for DragonX cryptocurrency. Built with Dear ImGui, SDL3, and OpenGL3/DX11. Features: - Send/receive shielded and transparent transactions - Autoshield with merged transaction display - Built-in CPU mining (xmrig) - Peer management and network monitoring - Wallet encryption with PIN lock - QR code generation for receive addresses - Transaction history with pagination - Console for direct RPC commands - Cross-platform (Linux, Windows)
This commit is contained in:
858
src/ui/windows/transactions_tab.cpp
Normal file
858
src/ui/windows/transactions_tab.cpp
Normal file
@@ -0,0 +1,858 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#include "transactions_tab.h"
|
||||
#include "transaction_details_dialog.h"
|
||||
#include "export_transactions_dialog.h"
|
||||
#include "../../app.h"
|
||||
#include "../../config/settings.h"
|
||||
#include "../../config/version.h"
|
||||
#include "../theme.h"
|
||||
#include "../effects/imgui_acrylic.h"
|
||||
#include "../layout.h"
|
||||
#include "../schema/ui_schema.h"
|
||||
#include "../material/type.h"
|
||||
#include "../material/draw_helpers.h"
|
||||
#include "../material/colors.h"
|
||||
#include "../../embedded/IconsMaterialDesign.h"
|
||||
#include "imgui.h"
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <ctime>
|
||||
#include <cmath>
|
||||
#include <unordered_map>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
using namespace material;
|
||||
|
||||
// Helper to truncate strings
|
||||
static std::string truncateString(const std::string& str, int maxLen = 16) {
|
||||
if (str.length() <= static_cast<size_t>(maxLen)) return str;
|
||||
int half = (maxLen - 3) / 2;
|
||||
return str.substr(0, half) + "..." + str.substr(str.length() - half);
|
||||
}
|
||||
|
||||
// Case-insensitive string search
|
||||
static bool containsIgnoreCase(const std::string& str, const std::string& search) {
|
||||
if (search.empty()) return true;
|
||||
std::string str_lower = str;
|
||||
std::string search_lower = search;
|
||||
std::transform(str_lower.begin(), str_lower.end(), str_lower.begin(), ::tolower);
|
||||
std::transform(search_lower.begin(), search_lower.end(), search_lower.begin(), ::tolower);
|
||||
return str_lower.find(search_lower) != std::string::npos;
|
||||
}
|
||||
|
||||
// A display-ready transaction that may be a merged autoshield pair.
|
||||
// For non-merged entries, send_idx or recv_idx is -1.
|
||||
struct DisplayTx {
|
||||
std::string txid;
|
||||
std::string display_type; // "send", "receive", "mined", "immature", "shield"
|
||||
double amount = 0.0;
|
||||
int64_t timestamp = 0;
|
||||
int confirmations = 0;
|
||||
std::string address; // primary display address
|
||||
std::string from_address;
|
||||
std::string memo;
|
||||
int orig_idx = -1; // index into state.transactions (first/primary)
|
||||
int send_idx = -1; // index of send leg (shield only)
|
||||
int recv_idx = -1; // index of recv leg (shield only)
|
||||
bool is_shield = false;
|
||||
|
||||
bool isConfirmed() const { return confirmations >= 1; }
|
||||
std::string getTimeString() const;
|
||||
};
|
||||
|
||||
std::string DisplayTx::getTimeString() const {
|
||||
if (timestamp <= 0) return "Pending";
|
||||
std::time_t t = static_cast<std::time_t>(timestamp);
|
||||
char buf[64];
|
||||
std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M", std::localtime(&t));
|
||||
return buf;
|
||||
}
|
||||
|
||||
// Relative time string
|
||||
static std::string timeAgo(int64_t timestamp) {
|
||||
if (timestamp <= 0) return "";
|
||||
int64_t now = (int64_t)std::time(nullptr);
|
||||
int64_t diff = now - timestamp;
|
||||
if (diff < 0) diff = 0;
|
||||
if (diff < 60) return std::to_string(diff) + "s ago";
|
||||
if (diff < 3600) return std::to_string(diff / 60) + "m ago";
|
||||
if (diff < 86400) return std::to_string(diff / 3600) + "h ago";
|
||||
return std::to_string(diff / 86400) + "d ago";
|
||||
}
|
||||
|
||||
// Draw a small transaction-type icon
|
||||
static void DrawTxIcon(ImDrawList* dl, const std::string& type,
|
||||
float cx, float cy, float /*s*/, ImU32 col)
|
||||
{
|
||||
using namespace material;
|
||||
ImFont* iconFont = Type().iconSmall();
|
||||
const char* icon;
|
||||
if (type == "send") {
|
||||
icon = ICON_MD_CALL_MADE;
|
||||
} else if (type == "receive") {
|
||||
icon = ICON_MD_CALL_RECEIVED;
|
||||
} else if (type == "shield") {
|
||||
icon = ICON_MD_SHIELD;
|
||||
} else {
|
||||
icon = ICON_MD_CONSTRUCTION;
|
||||
}
|
||||
ImVec2 sz = iconFont->CalcTextSizeA(iconFont->LegacySize, 1000.0f, 0.0f, icon);
|
||||
dl->AddText(iconFont, iconFont->LegacySize,
|
||||
ImVec2(cx - sz.x * 0.5f, cy - sz.y * 0.5f), col, icon);
|
||||
}
|
||||
|
||||
void RenderTransactionsTab(App* app)
|
||||
{
|
||||
auto& S = schema::UISchema::instance();
|
||||
const auto searchInput = S.input("tabs.transactions", "search-input");
|
||||
const auto filterCombo = S.combo("tabs.transactions", "filter-combo");
|
||||
const auto filterGapEl = S.drawElement("tabs.transactions", "filter-gap");
|
||||
const auto txTable = S.table("tabs.transactions", "transaction-table");
|
||||
const auto addrLabel = S.label("tabs.transactions", "address-label");
|
||||
const auto& state = app->state();
|
||||
|
||||
// Responsive scale factors (recomputed every frame)
|
||||
ImVec2 contentAvail = ImGui::GetContentRegionAvail();
|
||||
const float hs = Layout::hScale(contentAvail.x);
|
||||
const float vs = Layout::vScale(contentAvail.y);
|
||||
const float glassRound = Layout::glassRounding();
|
||||
const float innerPad = Layout::cardInnerPadding();
|
||||
const float cGap = Layout::cardGap();
|
||||
|
||||
// Non-scrolling container — content resizes to fit available height
|
||||
ImVec2 txAvail = ImGui::GetContentRegionAvail();
|
||||
ImGui::BeginChild("##TxScroll", txAvail, false,
|
||||
ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
|
||||
|
||||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||||
GlassPanelSpec glassSpec;
|
||||
glassSpec.rounding = glassRound;
|
||||
ImFont* ovFont = Type().overline();
|
||||
ImFont* capFont = Type().caption();
|
||||
ImFont* body2 = Type().body2();
|
||||
char buf[128];
|
||||
|
||||
ImU32 greenCol = Success();
|
||||
ImU32 redCol = Error();
|
||||
ImU32 goldCol = Warning();
|
||||
|
||||
// Expanded row index for inline detail
|
||||
static int s_expanded_row = -1;
|
||||
|
||||
// Pagination state
|
||||
static int s_current_page = 0;
|
||||
static int s_prev_filter_hash = 0; // detect filter changes to reset page
|
||||
|
||||
// ================================================================
|
||||
// Summary Cards — Received | Sent | Mined
|
||||
// ================================================================
|
||||
{
|
||||
int recvCount = 0, sendCount = 0, minedCount = 0;
|
||||
double recvTotal = 0.0, sendTotal = 0.0, minedTotal = 0.0;
|
||||
|
||||
for (const auto& tx : state.transactions) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
float availWidth = ImGui::GetContentRegionAvail().x;
|
||||
float cardGap = cGap;
|
||||
float cardW = (availWidth - 2 * cardGap) / 3.0f;
|
||||
float cardH = Layout::cardHeight(70.0f, vs);
|
||||
float iconSz = std::max(4.0f, schema::UI().drawElement("tabs.transactions", "summary-icon-size").size * hs);
|
||||
ImVec2 origin = ImGui::GetCursorScreenPos();
|
||||
|
||||
// Clickable type filter (clicking a card sets the type filter)
|
||||
static int type_filter = 0;
|
||||
|
||||
// --- Received card ---
|
||||
{
|
||||
ImVec2 cMin = origin;
|
||||
ImVec2 cMax(cMin.x + cardW, cMin.y + cardH);
|
||||
DrawGlassPanel(dl, cMin, cMax, glassSpec);
|
||||
|
||||
float cx = cMin.x + innerPad;
|
||||
float cy = cMin.y + Layout::spacingMd();
|
||||
|
||||
// Icon
|
||||
DrawTxIcon(dl, "receive", cx + iconSz, cy + iconSz * 1.33f, iconSz, greenCol);
|
||||
|
||||
float labelX = cx + iconSz * 3.0f;
|
||||
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(labelX, cy), OnSurfaceMedium(), "RECEIVED");
|
||||
cy += ovFont->LegacySize + Layout::spacingSm();
|
||||
|
||||
snprintf(buf, sizeof(buf), "%d txs", recvCount);
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, cy), OnSurfaceDisabled(), buf);
|
||||
cy += capFont->LegacySize + Layout::spacingXs();
|
||||
|
||||
snprintf(buf, sizeof(buf), "+%.4f %s", recvTotal, DRAGONX_TICKER);
|
||||
dl->AddText(body2, body2->LegacySize, ImVec2(cx, cy), greenCol, buf);
|
||||
|
||||
if (material::IsRectHovered(cMin, cMax)) {
|
||||
dl->AddRect(cMin, cMax, IM_COL32(255, 255, 255, 40), glassSpec.rounding, 0, 1.5f);
|
||||
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||
if (ImGui::IsMouseClicked(0)) type_filter = (type_filter == 2) ? 0 : 2;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Sent card ---
|
||||
{
|
||||
float xOff = cardW + cardGap;
|
||||
ImVec2 cMin(origin.x + xOff, origin.y);
|
||||
ImVec2 cMax(cMin.x + cardW, cMin.y + cardH);
|
||||
DrawGlassPanel(dl, cMin, cMax, glassSpec);
|
||||
|
||||
float cx = cMin.x + innerPad;
|
||||
float cy = cMin.y + Layout::spacingMd();
|
||||
|
||||
DrawTxIcon(dl, "send", cx + iconSz, cy + iconSz * 1.33f, iconSz, redCol);
|
||||
|
||||
float labelX = cx + iconSz * 3.0f;
|
||||
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(labelX, cy), OnSurfaceMedium(), "SENT");
|
||||
cy += ovFont->LegacySize + Layout::spacingSm();
|
||||
|
||||
snprintf(buf, sizeof(buf), "%d txs", sendCount);
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, cy), OnSurfaceDisabled(), buf);
|
||||
cy += capFont->LegacySize + Layout::spacingXs();
|
||||
|
||||
snprintf(buf, sizeof(buf), "-%.4f %s", sendTotal, DRAGONX_TICKER);
|
||||
dl->AddText(body2, body2->LegacySize, ImVec2(cx, cy), redCol, buf);
|
||||
|
||||
if (material::IsRectHovered(cMin, cMax)) {
|
||||
dl->AddRect(cMin, cMax, IM_COL32(255, 255, 255, 40), glassSpec.rounding, 0, 1.5f);
|
||||
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||
if (ImGui::IsMouseClicked(0)) type_filter = (type_filter == 1) ? 0 : 1;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Mined card ---
|
||||
{
|
||||
float xOff = 2 * (cardW + cardGap);
|
||||
ImVec2 cMin(origin.x + xOff, origin.y);
|
||||
ImVec2 cMax(cMin.x + cardW, cMin.y + cardH);
|
||||
DrawGlassPanel(dl, cMin, cMax, glassSpec);
|
||||
|
||||
float cx = cMin.x + innerPad;
|
||||
float cy = cMin.y + Layout::spacingMd();
|
||||
|
||||
DrawTxIcon(dl, "mined", cx + iconSz, cy + iconSz * 1.33f, iconSz, goldCol);
|
||||
|
||||
float labelX = cx + iconSz * 3.0f;
|
||||
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(labelX, cy), OnSurfaceMedium(), "MINED");
|
||||
cy += ovFont->LegacySize + Layout::spacingSm();
|
||||
|
||||
snprintf(buf, sizeof(buf), "%d txs", minedCount);
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, cy), OnSurfaceDisabled(), buf);
|
||||
cy += capFont->LegacySize + Layout::spacingXs();
|
||||
|
||||
snprintf(buf, sizeof(buf), "+%.4f %s", minedTotal, DRAGONX_TICKER);
|
||||
dl->AddText(body2, body2->LegacySize, ImVec2(cx, cy), goldCol, buf);
|
||||
|
||||
if (material::IsRectHovered(cMin, cMax)) {
|
||||
dl->AddRect(cMin, cMax, IM_COL32(255, 255, 255, 40), glassSpec.rounding, 0, 1.5f);
|
||||
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||
if (ImGui::IsMouseClicked(0)) type_filter = (type_filter == 3) ? 0 : 3;
|
||||
}
|
||||
}
|
||||
|
||||
// Selected card accent
|
||||
if (type_filter > 0) {
|
||||
int idx_map[] = {-1, 1, 0, 2};
|
||||
int idx = idx_map[type_filter];
|
||||
float xOff = idx * (cardW + cardGap);
|
||||
ImVec2 acMin(origin.x + xOff, origin.y + cardH - 3);
|
||||
ImVec2 acMax(origin.x + xOff + cardW, origin.y + cardH);
|
||||
ImU32 acCol = (type_filter == 1) ? redCol : (type_filter == 2) ? greenCol : goldCol;
|
||||
dl->AddRectFilled(acMin, acMax, acCol, 2.0f);
|
||||
}
|
||||
|
||||
ImGui::Dummy(ImVec2(availWidth, cardH));
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
|
||||
|
||||
// ================================================================
|
||||
// Search & Filter bar
|
||||
// ================================================================
|
||||
static char search_filter[128] = "";
|
||||
float searchMaxW = (searchInput.maxWidth >= 0) ? searchInput.maxWidth : 300.0f;
|
||||
float searchRatio = (searchInput.widthRatio >= 0) ? searchInput.widthRatio : 0.30f;
|
||||
float searchWidth = std::min(searchMaxW * hs, availWidth * searchRatio);
|
||||
|
||||
ImGui::SetNextItemWidth(searchWidth);
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, Layout::spacingSm());
|
||||
ImGui::InputTextWithHint("##TxSearch", "Search...", search_filter, sizeof(search_filter));
|
||||
ImGui::PopStyleVar();
|
||||
|
||||
float filterGap = std::max(8.0f, ((filterGapEl.size > 0) ? filterGapEl.size : 20.0f) * hs);
|
||||
ImGui::SameLine(0, filterGap);
|
||||
float comboW = std::max(80.0f, ((filterCombo.width > 0) ? filterCombo.width : 120.0f) * hs);
|
||||
ImGui::SetNextItemWidth(comboW);
|
||||
const char* types[] = { "All", "Sent", "Received", "Mined" };
|
||||
ImGui::Combo("##TxType", &type_filter, types, IM_ARRAYSIZE(types));
|
||||
|
||||
ImGui::SameLine(0, filterGap);
|
||||
if (TactileButton("Refresh", ImVec2(0, 0), S.resolveFont("button"))) {
|
||||
app->refreshNow();
|
||||
}
|
||||
|
||||
ImGui::SameLine(0, filterGap);
|
||||
if (TactileButton("Export CSV", ImVec2(0, 0), S.resolveFont("button"))) {
|
||||
ExportTransactionsDialog::show();
|
||||
}
|
||||
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingSm() + Layout::spacingXs()));
|
||||
|
||||
// ================================================================
|
||||
// Transaction list — DrawList-based rows in scrollable child
|
||||
// ================================================================
|
||||
|
||||
int filtered_count = 0;
|
||||
std::string search_str(search_filter);
|
||||
|
||||
// Build display list, merging autoshield send+receive pairs.
|
||||
// Two entries sharing the same txid where one is "send" and
|
||||
// one is "receive" to a z-address are combined into a single
|
||||
// "shield" entry. We first index by txid, then build the list.
|
||||
std::vector<DisplayTx> display_txns;
|
||||
{
|
||||
// Map txid -> indices in state.transactions
|
||||
std::unordered_map<std::string, std::vector<size_t>> txid_map;
|
||||
for (size_t i = 0; i < state.transactions.size(); i++) {
|
||||
txid_map[state.transactions[i].txid].push_back(i);
|
||||
}
|
||||
|
||||
std::vector<bool> consumed(state.transactions.size(), false);
|
||||
|
||||
for (size_t i = 0; i < state.transactions.size(); i++) {
|
||||
if (consumed[i]) continue;
|
||||
const auto& tx = state.transactions[i];
|
||||
|
||||
// Try to find an autoshield pair (same txid, send+receive to z-addr)
|
||||
bool merged = false;
|
||||
const auto& siblings = txid_map[tx.txid];
|
||||
if (siblings.size() >= 2) {
|
||||
int send_i = -1, recv_i = -1;
|
||||
for (size_t si : siblings) {
|
||||
if (consumed[si]) continue;
|
||||
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) recv_i = (int)si;
|
||||
}
|
||||
if (send_i >= 0 && recv_i >= 0) {
|
||||
const auto& stx = state.transactions[send_i];
|
||||
const auto& rtx = state.transactions[recv_i];
|
||||
// Confirm receive goes to a z-address (shielded)
|
||||
bool recv_is_shielded = !rtx.address.empty() && rtx.address[0] == 'z';
|
||||
if (recv_is_shielded) {
|
||||
DisplayTx dtx;
|
||||
dtx.txid = tx.txid;
|
||||
dtx.display_type = "shield";
|
||||
dtx.is_shield = true;
|
||||
dtx.amount = rtx.amount; // positive receive amount
|
||||
dtx.timestamp = std::max(stx.timestamp, rtx.timestamp);
|
||||
dtx.confirmations = std::min(stx.confirmations, rtx.confirmations);
|
||||
dtx.address = rtx.address; // shielded destination
|
||||
dtx.from_address = stx.address.empty() ? stx.from_address : stx.address;
|
||||
dtx.memo = rtx.memo.empty() ? stx.memo : rtx.memo;
|
||||
dtx.orig_idx = send_i;
|
||||
dtx.send_idx = send_i;
|
||||
dtx.recv_idx = recv_i;
|
||||
consumed[send_i] = true;
|
||||
consumed[recv_i] = true;
|
||||
display_txns.push_back(std::move(dtx));
|
||||
merged = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!merged) {
|
||||
consumed[i] = true;
|
||||
DisplayTx dtx;
|
||||
dtx.txid = tx.txid;
|
||||
dtx.display_type = tx.type;
|
||||
dtx.amount = tx.amount;
|
||||
dtx.timestamp = tx.timestamp;
|
||||
dtx.confirmations = tx.confirmations;
|
||||
dtx.address = tx.address;
|
||||
dtx.from_address = tx.from_address;
|
||||
dtx.memo = tx.memo;
|
||||
dtx.orig_idx = (int)i;
|
||||
display_txns.push_back(std::move(dtx));
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by timestamp descending (same as raw list)
|
||||
std::sort(display_txns.begin(), display_txns.end(),
|
||||
[](const DisplayTx& a, const DisplayTx& b) {
|
||||
return a.timestamp > b.timestamp;
|
||||
});
|
||||
}
|
||||
|
||||
// Apply type + search filters
|
||||
std::vector<size_t> filtered_indices;
|
||||
for (size_t i = 0; i < display_txns.size(); i++) {
|
||||
const auto& dtx = display_txns[i];
|
||||
if (type_filter != 0) {
|
||||
if (type_filter == 1 && dtx.display_type != "send") continue;
|
||||
if (type_filter == 2 && dtx.display_type != "receive" && dtx.display_type != "shield") continue;
|
||||
if (type_filter == 3 && dtx.display_type != "generate" && dtx.display_type != "immature" && dtx.display_type != "mined") continue;
|
||||
}
|
||||
if (!search_str.empty()) {
|
||||
if (!containsIgnoreCase(dtx.address, search_str) &&
|
||||
!containsIgnoreCase(dtx.txid, search_str) &&
|
||||
!containsIgnoreCase(dtx.memo, search_str) &&
|
||||
!(dtx.is_shield && containsIgnoreCase(std::string("shielded"), search_str))) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
filtered_indices.push_back(i);
|
||||
}
|
||||
filtered_count = static_cast<int>(filtered_indices.size());
|
||||
|
||||
// Pagination — slice filtered results into pages
|
||||
int perPage = std::max(10, (int)schema::UI().drawElement("tabs.transactions", "rows-per-page").sizeOr(50.0f));
|
||||
int totalPages = std::max(1, (filtered_count + perPage - 1) / perPage);
|
||||
|
||||
// Reset page when filters change
|
||||
int filterHash = type_filter * 1000003 + filtered_count * 31 + static_cast<int>(search_str.size());
|
||||
if (filterHash != s_prev_filter_hash) {
|
||||
s_current_page = 0;
|
||||
s_prev_filter_hash = filterHash;
|
||||
}
|
||||
if (s_current_page >= totalPages) s_current_page = totalPages - 1;
|
||||
if (s_current_page < 0) s_current_page = 0;
|
||||
|
||||
int pageStart = s_current_page * perPage;
|
||||
int pageEnd = std::min(pageStart + perPage, filtered_count);
|
||||
|
||||
// ---- Heading line: "TRANSACTIONS" left, pagination right ----
|
||||
{
|
||||
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), "TRANSACTIONS");
|
||||
|
||||
if (totalPages > 1) {
|
||||
float paginationH = ImGui::GetFrameHeight();
|
||||
float btnW = paginationH;
|
||||
float gap = Layout::spacingSm();
|
||||
float pageNumW = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, " 999 / 999 ").x + Layout::spacingLg();
|
||||
float totalPagW = btnW * 4 + pageNumW + gap * 4;
|
||||
|
||||
// Right-align: position cursor so the group ends at the right edge
|
||||
ImGui::SameLine();
|
||||
float startX = ImGui::GetContentRegionMax().x - totalPagW;
|
||||
ImGui::SetCursorPosX(startX);
|
||||
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, Layout::spacingSm());
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_ButtonTextAlign, ImVec2(0.5f, 0.5f));
|
||||
ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(IM_COL32(255, 255, 255, 15)));
|
||||
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(IM_COL32(255, 255, 255, 30)));
|
||||
ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImGui::ColorConvertU32ToFloat4(IM_COL32(255, 255, 255, 45)));
|
||||
|
||||
// First page
|
||||
ImGui::BeginDisabled(s_current_page == 0);
|
||||
ImGui::PushFont(Type().iconSmall());
|
||||
if (ImGui::Button(ICON_MD_FIRST_PAGE "##txFirst", ImVec2(btnW, btnW))) {
|
||||
s_current_page = 0;
|
||||
s_expanded_row = -1;
|
||||
}
|
||||
ImGui::PopFont();
|
||||
ImGui::EndDisabled();
|
||||
|
||||
ImGui::SameLine(0, gap);
|
||||
|
||||
// Previous page
|
||||
ImGui::BeginDisabled(s_current_page == 0);
|
||||
ImGui::PushFont(Type().iconSmall());
|
||||
if (ImGui::Button(ICON_MD_CHEVRON_LEFT "##txPrev", ImVec2(btnW, btnW))) {
|
||||
s_current_page--;
|
||||
s_expanded_row = -1;
|
||||
}
|
||||
ImGui::PopFont();
|
||||
ImGui::EndDisabled();
|
||||
|
||||
ImGui::SameLine(0, gap);
|
||||
|
||||
// Page indicator — render centered text over a fixed-width dummy
|
||||
{
|
||||
snprintf(buf, sizeof(buf), "%d / %d", s_current_page + 1, totalPages);
|
||||
ImVec2 pageSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, buf);
|
||||
ImVec2 regionPos = ImGui::GetCursorScreenPos();
|
||||
// Reserve the fixed width without advancing to a new line
|
||||
ImGui::InvisibleButton("##pageNum", ImVec2(pageNumW, paginationH));
|
||||
// Draw the text centered within that reserved area
|
||||
ImVec2 textPos(
|
||||
regionPos.x + (pageNumW - pageSz.x) * 0.5f,
|
||||
regionPos.y + (paginationH - pageSz.y) * 0.5f
|
||||
);
|
||||
ImGui::GetWindowDrawList()->AddText(capFont, capFont->LegacySize, textPos, OnSurfaceMedium(), buf);
|
||||
}
|
||||
|
||||
ImGui::SameLine(0, gap);
|
||||
|
||||
// Next page
|
||||
ImGui::BeginDisabled(s_current_page >= totalPages - 1);
|
||||
ImGui::PushFont(Type().iconSmall());
|
||||
if (ImGui::Button(ICON_MD_CHEVRON_RIGHT "##txNext", ImVec2(btnW, btnW))) {
|
||||
s_current_page++;
|
||||
s_expanded_row = -1;
|
||||
}
|
||||
ImGui::PopFont();
|
||||
ImGui::EndDisabled();
|
||||
|
||||
ImGui::SameLine(0, gap);
|
||||
|
||||
// Last page
|
||||
ImGui::BeginDisabled(s_current_page >= totalPages - 1);
|
||||
ImGui::PushFont(Type().iconSmall());
|
||||
if (ImGui::Button(ICON_MD_LAST_PAGE "##txLast", ImVec2(btnW, btnW))) {
|
||||
s_current_page = totalPages - 1;
|
||||
s_expanded_row = -1;
|
||||
}
|
||||
ImGui::PopFont();
|
||||
ImGui::EndDisabled();
|
||||
|
||||
ImGui::PopStyleColor(3);
|
||||
ImGui::PopStyleVar(2);
|
||||
}
|
||||
}
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
||||
|
||||
// Glass panel wrapping the list area — scale reserve with vScale
|
||||
float scaledReserve = (txTable.bottomReserve > 0) ? txTable.bottomReserve : std::max(schema::UI().drawElement("tabs.transactions", "bottom-reserve-min").size, schema::UI().drawElement("tabs.transactions", "bottom-reserve-base").size * vs);
|
||||
float listH = ImGui::GetContentRegionAvail().y - scaledReserve;
|
||||
float minListH = std::max(schema::UI().drawElement("tabs.transactions", "list-min-height").size, schema::UI().drawElement("tabs.transactions", "list-base-height").size * vs);
|
||||
if (listH < minListH) listH = minListH;
|
||||
|
||||
ImVec2 listPanelMin = ImGui::GetCursorScreenPos();
|
||||
ImVec2 listPanelMax(listPanelMin.x + availWidth, listPanelMin.y + listH);
|
||||
DrawGlassPanel(dl, listPanelMin, listPanelMax, glassSpec);
|
||||
|
||||
// Scroll state for clipping mask (captured inside child, used after EndChild)
|
||||
float scrollY = 0.0f;
|
||||
float scrollMaxY = 0.0f;
|
||||
|
||||
// Vertex start indices for CSS-style clipping mask (alpha fade at edges)
|
||||
int vtxMaskStart = dl->VtxBuffer.Size;
|
||||
|
||||
ImGui::BeginChild("##TxList", ImVec2(availWidth, listH), false,
|
||||
ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollWithMouse);
|
||||
ApplySmoothScroll();
|
||||
ImDrawList* childDL = ImGui::GetWindowDrawList();
|
||||
int childVtxStart = childDL->VtxBuffer.Size;
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||
{
|
||||
if (!app->isConnected()) {
|
||||
ImGui::Dummy(ImVec2(0, 20));
|
||||
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), " Not connected to daemon...");
|
||||
} else if (state.transactions.empty()) {
|
||||
ImGui::Dummy(ImVec2(0, 20));
|
||||
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), " No transactions found");
|
||||
} else if (filtered_indices.empty()) {
|
||||
ImGui::Dummy(ImVec2(0, 20));
|
||||
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), " No matching transactions");
|
||||
} else {
|
||||
float rowH = body2->LegacySize + capFont->LegacySize + Layout::spacingLg() + Layout::spacingMd();
|
||||
float innerW = ImGui::GetContentRegionAvail().x;
|
||||
float rowIconSz = std::max(3.5f, schema::UI().drawElement("tabs.transactions", "row-icon-size").size * hs);
|
||||
float rowPadLeft = Layout::spacingLg();
|
||||
|
||||
// Scroll state for gradient overlay fade (drawn after EndChild)
|
||||
scrollY = ImGui::GetScrollY();
|
||||
scrollMaxY = ImGui::GetScrollMaxY();
|
||||
|
||||
// Viewport culling bounds
|
||||
float viewTop = scrollY;
|
||||
float viewBot = scrollY + ImGui::GetWindowHeight();
|
||||
|
||||
// Render only the current page slice
|
||||
for (int fi = pageStart; fi < pageEnd; fi++) {
|
||||
size_t i = filtered_indices[fi];
|
||||
const auto& tx = display_txns[i];
|
||||
bool is_expanded = (s_expanded_row == static_cast<int>(i));
|
||||
|
||||
ImGui::PushID(static_cast<int>(i));
|
||||
ImVec2 rowPos = ImGui::GetCursorScreenPos();
|
||||
ImVec2 rowEnd(rowPos.x + innerW, rowPos.y + rowH);
|
||||
|
||||
// Determine type info
|
||||
ImU32 iconCol;
|
||||
const char* typeStr;
|
||||
if (tx.display_type == "shield") {
|
||||
iconCol = Primary(); typeStr = "Shielded";
|
||||
} else if (tx.display_type == "receive") {
|
||||
iconCol = greenCol; typeStr = "Recv";
|
||||
} else if (tx.display_type == "send") {
|
||||
iconCol = redCol; typeStr = "Sent";
|
||||
} else if (tx.display_type == "immature") {
|
||||
iconCol = Warning(); typeStr = "Immature";
|
||||
} else {
|
||||
iconCol = goldCol; typeStr = "Mined";
|
||||
}
|
||||
|
||||
// Expanded selection accent
|
||||
if (is_expanded) {
|
||||
dl->AddRectFilled(rowPos, rowEnd, IM_COL32(255, 255, 255, 20), schema::UI().drawElement("tabs.transactions", "row-hover-rounding").size);
|
||||
dl->AddRectFilled(rowPos, ImVec2(rowPos.x + schema::UI().drawElement("tabs.transactions", "row-accent-width").size, rowEnd.y), Primary(), schema::UI().drawElement("tabs.transactions", "accent-bar-rounding").size);
|
||||
}
|
||||
|
||||
// Hover glow
|
||||
bool hovered = material::IsRectHovered(rowPos, rowEnd);
|
||||
if (hovered && !is_expanded) {
|
||||
dl->AddRectFilled(rowPos, rowEnd, IM_COL32(255, 255, 255, 15), schema::UI().drawElement("tabs.transactions", "row-hover-rounding").size);
|
||||
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||
}
|
||||
|
||||
float cx = rowPos.x + rowPadLeft;
|
||||
float cy = rowPos.y + Layout::spacingMd();
|
||||
|
||||
// Icon
|
||||
DrawTxIcon(dl, tx.display_type, cx + rowIconSz, cy + body2->LegacySize * 0.5f, rowIconSz, iconCol);
|
||||
|
||||
// Type label
|
||||
float labelX = cx + rowIconSz * 2.0f + Layout::spacingSm();
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(labelX, cy), iconCol, typeStr);
|
||||
|
||||
// Time (next to type)
|
||||
std::string ago = timeAgo(tx.timestamp);
|
||||
float typeW = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, typeStr).x;
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(labelX + typeW + Layout::spacingLg(), cy),
|
||||
OnSurfaceDisabled(), ago.c_str());
|
||||
|
||||
// Address (second line, left side)
|
||||
std::string addr_display = truncateString(tx.address, (addrLabel.truncate > 0) ? addrLabel.truncate : 20);
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(labelX, cy + body2->LegacySize + Layout::spacingXs()),
|
||||
OnSurfaceMedium(), addr_display.c_str());
|
||||
|
||||
// Amount (right-aligned, first line)
|
||||
ImU32 amtCol = (tx.amount >= 0) ? greenCol : redCol;
|
||||
if (tx.amount >= 0)
|
||||
snprintf(buf, sizeof(buf), "+%.8f", tx.amount);
|
||||
else
|
||||
snprintf(buf, sizeof(buf), "%.8f", tx.amount);
|
||||
ImVec2 amtSz = body2->CalcTextSizeA(body2->LegacySize, FLT_MAX, 0, buf);
|
||||
float amtX = rowPos.x + innerW - amtSz.x - Layout::spacingLg();
|
||||
DrawTextShadow(dl, body2, body2->LegacySize, ImVec2(amtX, cy), amtCol, buf,
|
||||
1.0f, 1.0f, IM_COL32(0, 0, 0, 120));
|
||||
|
||||
// USD equivalent (right-aligned, second line)
|
||||
double priceUsd = state.market.price_usd;
|
||||
if (priceUsd > 0.0) {
|
||||
double usdVal = std::abs(tx.amount) * priceUsd;
|
||||
if (usdVal >= 1.0)
|
||||
snprintf(buf, sizeof(buf), "$%.2f", usdVal);
|
||||
else if (usdVal >= 0.01)
|
||||
snprintf(buf, sizeof(buf), "$%.4f", usdVal);
|
||||
else
|
||||
snprintf(buf, sizeof(buf), "$%.6f", usdVal);
|
||||
ImVec2 usdSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, buf);
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(rowPos.x + innerW - usdSz.x - Layout::spacingLg(), cy + body2->LegacySize + Layout::spacingXs()),
|
||||
OnSurfaceDisabled(), buf);
|
||||
}
|
||||
|
||||
// Status badge (centered area, second line)
|
||||
{
|
||||
const char* statusStr;
|
||||
ImU32 statusCol;
|
||||
if (tx.confirmations == 0) {
|
||||
statusStr = "Pending"; statusCol = Warning();
|
||||
} else if (tx.confirmations < 10) {
|
||||
snprintf(buf, sizeof(buf), "%d conf", tx.confirmations);
|
||||
statusStr = buf; statusCol = Warning();
|
||||
} else if (tx.confirmations >= 100 && (tx.display_type == "generate" || tx.display_type == "mined")) {
|
||||
statusStr = "Mature"; statusCol = greenCol;
|
||||
} else {
|
||||
statusStr = "Confirmed"; statusCol = WithAlpha(Success(), 140);
|
||||
}
|
||||
// Position status badge in the middle-right area
|
||||
ImVec2 sSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, statusStr);
|
||||
float statusX = amtX - sSz.x - Layout::spacingXxl();
|
||||
float minStatusX = cx + innerW * 0.25f; // don't overlap address
|
||||
if (statusX < minStatusX) statusX = minStatusX;
|
||||
// Background pill
|
||||
ImU32 pillBg = (statusCol & 0x00FFFFFFu) | (static_cast<ImU32>(30) << 24);
|
||||
ImVec2 pillMin(statusX - Layout::spacingSm(), cy + body2->LegacySize + 1);
|
||||
ImVec2 pillMax(statusX + sSz.x + Layout::spacingSm(), pillMin.y + capFont->LegacySize + Layout::spacingXs());
|
||||
dl->AddRectFilled(pillMin, pillMax, pillBg, schema::UI().drawElement("tabs.transactions", "status-pill-rounding").size);
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(statusX, cy + body2->LegacySize + Layout::spacingXs()), statusCol, statusStr);
|
||||
}
|
||||
|
||||
// Click to expand/collapse + invisible button for interaction
|
||||
ImGui::InvisibleButton("##txRow", ImVec2(innerW, rowH));
|
||||
if (ImGui::IsItemClicked(0)) {
|
||||
s_expanded_row = is_expanded ? -1 : static_cast<int>(i);
|
||||
}
|
||||
|
||||
// Tooltip
|
||||
if (ImGui::IsItemHovered()) {
|
||||
ImGui::SetTooltip("%s\n%s\n%s", tx.address.c_str(),
|
||||
tx.txid.c_str(), tx.getTimeString().c_str());
|
||||
}
|
||||
|
||||
// Context menu
|
||||
const auto& acrylicTheme = GetCurrentAcrylicTheme();
|
||||
if (effects::ImGuiAcrylic::BeginAcrylicContextItem("TxContext", 0, acrylicTheme.menu)) {
|
||||
if (ImGui::MenuItem("Copy Address") && !tx.address.empty()) {
|
||||
ImGui::SetClipboardText(tx.address.c_str());
|
||||
}
|
||||
if (ImGui::MenuItem("Copy TxID")) {
|
||||
ImGui::SetClipboardText(tx.txid.c_str());
|
||||
}
|
||||
ImGui::Separator();
|
||||
if (ImGui::MenuItem("View on Explorer")) {
|
||||
std::string url = app->settings()->getTxExplorerUrl() + tx.txid;
|
||||
#ifdef _WIN32
|
||||
ShellExecuteA(nullptr, "open", url.c_str(), nullptr, nullptr, SW_SHOWNORMAL);
|
||||
#elif __APPLE__
|
||||
std::string cmd = "open \"" + url + "\"";
|
||||
system(cmd.c_str());
|
||||
#else
|
||||
std::string cmd = "xdg-open \"" + url + "\" &";
|
||||
system(cmd.c_str());
|
||||
#endif
|
||||
}
|
||||
if (ImGui::MenuItem("View Details")) {
|
||||
if (tx.orig_idx >= 0 && tx.orig_idx < (int)state.transactions.size())
|
||||
TransactionDetailsDialog::show(state.transactions[tx.orig_idx]);
|
||||
}
|
||||
effects::ImGuiAcrylic::EndAcrylicPopup();
|
||||
}
|
||||
|
||||
// ---- Inline detail expansion ----
|
||||
if (is_expanded) {
|
||||
ImVec2 detailPos = ImGui::GetCursorScreenPos();
|
||||
// We'll draw the glass panel after measuring the content
|
||||
float detailPad = Layout::spacingLg();
|
||||
float detailW = innerW - detailPad * 2;
|
||||
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + detailPad);
|
||||
ImGui::BeginGroup();
|
||||
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + detailW);
|
||||
|
||||
// From address
|
||||
if (!tx.from_address.empty()) {
|
||||
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), "FROM");
|
||||
ImGui::TextWrapped("%s", tx.from_address.c_str());
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||
}
|
||||
|
||||
// To address
|
||||
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(),
|
||||
tx.display_type == "send" ? "TO" : (tx.display_type == "shield" ? "SHIELDED TO" : "ADDRESS"));
|
||||
ImGui::TextWrapped("%s", tx.address.c_str());
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||
|
||||
// TxID (full, copyable)
|
||||
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), "TRANSACTION ID");
|
||||
ImGui::TextWrapped("%s", tx.txid.c_str());
|
||||
if (ImGui::IsItemHovered()) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||
if (ImGui::IsItemClicked()) ImGui::SetClipboardText(tx.txid.c_str());
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||
|
||||
// Memo
|
||||
if (!tx.memo.empty()) {
|
||||
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), "MEMO");
|
||||
ImGui::TextWrapped("%s", tx.memo.c_str());
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||
}
|
||||
|
||||
// Confirmations + time
|
||||
snprintf(buf, sizeof(buf), "%d confirmations | %s",
|
||||
tx.confirmations, tx.getTimeString().c_str());
|
||||
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), buf);
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||
|
||||
// Action buttons
|
||||
ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(IM_COL32(255, 255, 255, 15)));
|
||||
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(IM_COL32(255, 255, 255, 30)));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, schema::UI().drawElement("tabs.transactions", "detail-btn-rounding").size);
|
||||
if (TactileSmallButton("Copy TxID##detail", S.resolveFont("button"))) {
|
||||
ImGui::SetClipboardText(tx.txid.c_str());
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (!tx.address.empty() && TactileSmallButton("Copy Address##detail", S.resolveFont("button"))) {
|
||||
ImGui::SetClipboardText(tx.address.c_str());
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (TactileSmallButton("Explorer##detail", S.resolveFont("button"))) {
|
||||
std::string url = app->settings()->getTxExplorerUrl() + tx.txid;
|
||||
#ifdef _WIN32
|
||||
ShellExecuteA(nullptr, "open", url.c_str(), nullptr, nullptr, SW_SHOWNORMAL);
|
||||
#elif __APPLE__
|
||||
std::string cmd2 = "open \"" + url + "\"";
|
||||
system(cmd2.c_str());
|
||||
#else
|
||||
std::string cmd2 = "xdg-open \"" + url + "\" &";
|
||||
system(cmd2.c_str());
|
||||
#endif
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (TactileSmallButton("Full Details##detail", S.resolveFont("button"))) {
|
||||
if (tx.orig_idx >= 0 && tx.orig_idx < (int)state.transactions.size())
|
||||
TransactionDetailsDialog::show(state.transactions[tx.orig_idx]);
|
||||
}
|
||||
ImGui::PopStyleVar();
|
||||
ImGui::PopStyleColor(2);
|
||||
|
||||
ImGui::PopTextWrapPos();
|
||||
ImGui::EndGroup();
|
||||
|
||||
// Draw glass panel behind detail area
|
||||
ImVec2 detailEnd = ImGui::GetCursorScreenPos();
|
||||
float detailH = detailEnd.y - detailPos.y + Layout::spacingMd();
|
||||
GlassPanelSpec detailGlass;
|
||||
detailGlass.rounding = glassRound * 0.75f;
|
||||
detailGlass.fillAlpha = 25;
|
||||
DrawGlassPanel(dl, ImVec2(detailPos.x + Layout::spacingSm() + Layout::spacingXs(), detailPos.y),
|
||||
ImVec2(detailPos.x + innerW - Layout::spacingSm() - Layout::spacingXs(), detailPos.y + detailH), detailGlass);
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
|
||||
}
|
||||
|
||||
// Subtle divider between rows
|
||||
if (fi < pageEnd - 1 && !is_expanded) {
|
||||
ImVec2 divStart = ImGui::GetCursorScreenPos();
|
||||
dl->AddLine(ImVec2(divStart.x + rowPadLeft + rowIconSz * 2.0f, divStart.y),
|
||||
ImVec2(divStart.x + innerW - Layout::spacingLg(), divStart.y),
|
||||
IM_COL32(255, 255, 255, 15));
|
||||
}
|
||||
|
||||
ImGui::PopID();
|
||||
}
|
||||
}
|
||||
}
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||
ImGui::EndChild();
|
||||
|
||||
// CSS-style clipping mask
|
||||
{
|
||||
float fadeZone = std::min(
|
||||
(body2->LegacySize + capFont->LegacySize + Layout::spacingLg() + Layout::spacingMd()) * 1.2f,
|
||||
listH * 0.18f);
|
||||
ApplyScrollEdgeMask(dl, vtxMaskStart, childDL, childVtxStart,
|
||||
listPanelMin.y, listPanelMax.y, fadeZone, scrollY, scrollMaxY);
|
||||
}
|
||||
|
||||
// Status line with page info
|
||||
snprintf(buf, sizeof(buf), "Showing %d\xe2\x80\x93%d of %d transactions (total: %zu)",
|
||||
filtered_count > 0 ? pageStart + 1 : 0, pageEnd, filtered_count, state.transactions.size());
|
||||
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), buf);
|
||||
}
|
||||
|
||||
ImGui::EndChild(); // ##TxScroll
|
||||
}
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
Reference in New Issue
Block a user