13 Commits

Author SHA1 Message Date
203967411a feat(chat): theme the :drgx: emoji to the accent (was fixed brand colors)
The custom DragonX chat emoji now recolors to the theme like the logo — body =
accent, detail = white on dark skins / on-surface (dark) on light skins — and
re-rasterizes on a theme/dark-light change (moved out of the one-time fixed-color
load into ensureLogoTexture's re-render block). The detail highlights keep it
legible even on accent-tinted outgoing bubbles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 14:12:44 -05:00
3d0305a9ca fix(ui): darken the logo SVG detail on light skins
The SVG's white highlight (detail) washed out against a light card/background on
light skins — the dragon's wings/detail nearly vanished, leaving only the accent
body. Make the detail colour theme-aware: white on dark skins (unchanged), the
theme's on-surface colour (dark) on light skins, so the full mark reads on both.
Applies to the header logo and the balance coin icon; re-rasterizes on the
dark↔light flip (already tracked by the logo guard). The fixed-brand ":drgx:"
emoji is unaffected (its crimson body carries white detail fine on any bubble).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 14:09:03 -05:00
e7f38c2a45 fix(ui): theme the balance-card coin icon per skin too (not just the header)
The prominent DragonX mark on the Overview (next to the balance) is the coin /
currency icon (coin_logo_tex_), a separate texture from the header logo — and it
was still loaded from the fixed logo_dragonx_128.png, so it never recolored when
switching themes. Route it through the same themed SVG: ensureLogoTexture() now
rasterizes BOTH the header logo and the coin icon from logo_dragonx.svg recolored
to the theme accent, re-rasterizing both together on a skin / dark-light change
(coin done first, since the header branch early-returns on success). The old
one-shot PNG coin-logo load in render() is removed (superseded), the PNG remains
a fallback, and the coin texture is now freed on theme switch (was leaked).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 14:02:13 -05:00
3a2be661ea feat(chat): custom DragonX emoji via the :drgx: shortcode
Add a DragonX custom emoji to chat. The emoji picker gains a DragonX tile (first
cell) that inserts the text ":drgx:"; message bodies containing it render the mark
inline via layoutChatBodyRich, which flows text words + the emoji image with word
wrap (plain bodies keep the tighter layoutChatBody path, so normal messages are
unaffected). Other clients simply show the literal ":drgx:" text — a portable
encoding with graceful degradation.

The emoji is the DragonX SVG rasterized once at fixed brand colors (crimson body,
white detail) via LoadTextureFromSvg — theme-independent so it looks identical for
sender and receiver — exposed as App::getDrgxEmojiTexture(). Emoji insertion is
factored into one insertToken() helper (space-prepend + on-chain byte cap), shared
by the DragonX tile and the Unicode emojis. Drag-to-select text is skipped on
":drgx:" messages (their inline layout doesn't match the plain-text hit-test
geometry); right-click "copy" still copies the whole message.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 13:42:30 -05:00
17525003c5 feat(ui): themed DragonX logo — runtime-rasterized SVG recolored per skin
Replace the shared app logo (Overview header, first-run wizard, lock screen) with
the DragonX mark rendered from res/img/logos/logo_dragonx.svg, recolored to each
theme: the dragon body takes the theme accent (Primary()), the inner detail stays
light. Vendor nanosvg (memononen, zlib/public-domain) in libs/nanosvg/ and add
util::LoadTextureFromSvg (parse a copy, recolor shapes by luminance, rasterize to
RGBA, upload via the existing CreateRawTexture — GL + DX11). The SVG is embedded as
a string (src/embedded/logo_dragonx_svg.h) so it's available in every build.

The load moves into App::ensureLogoTexture(), called at the top of render() BEFORE
the wizard/lock early-returns (so those screens get the logo too, which they never
did before), and re-rasterizes when the accent or the dark/light variant changes.
The per-skin PNG remains a fallback if rasterization ever fails; logo-texture
lifetime is now freed on replace + on theme switch (was leaked).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 13:28:51 -05:00
267d839d5b feat(chat): add a dragon-themed emoji set to the picker
Adds 🐉 🐲 🐍 🦎 🐊 🐾 🥚 🪺 🏰 ⚔ 🛡 🗡 to the emoji picker, with DRGX-flavoured
keywords (the dragons are searchable by "dragon"/"drgx"). All are single-codepoint
emoji verified present in both the bundled monochrome NotoEmoji subset and the
color Twemoji font, so no font rebuild is needed and they render on other clients
(standard Unicode, UTF-8 in the memo, within the 236-byte cap).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 12:45:49 -05:00
987d9b93c7 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>
2026-07-21 01:43:55 -05:00
4c43f2e082 fix(chat): draw the jump-to-latest pill on top of the messages
The pill was drawn on the parent window's draw list after EndChild, but ImGui
renders a child window ON TOP of its parent — so the message text covered it.
Draw it instead on the thread child's own draw list, after the message loop
(later draw commands render on top), with a PushClipRect so the child's content
padding / scrollbar doesn't clip it. Clickability is unchanged (hand-drawn +
IsMouseHoveringRect / IsMouseClicked test the cursor directly), and it stays
pinned to the visible bottom-right corner via absolute screen coordinates.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 01:23:23 -05:00
57fa6470b7 feat(chat): thread UX — auto-scroll, selectable text, clickable Latest, emoji polish
Five message-thread improvements:
- Auto-scroll like the Console tab: new messages pin to the bottom only while
  the reader is already there; a wheel-up detaches (re-armed on return to the
  bottom, after a cooldown). A direct mouse-in-rect test (not IsWindowHovered,
  which is false while the composer holds focus) matches ApplySmoothScroll so
  scrolling up to read history while typing still detaches.
- The "Latest" jump pill is now hand-drawn (rect + IsMouseHoveringRect /
  IsMouseClicked) instead of ImGui::Button: it overlaps the thread child which
  owns mouse-hover there, so a real widget on the parent never got the click.
  Clicking it re-arms auto-scroll; its geometry is shared with the deselect
  guard so a pill click no longer drops an active selection.
- Sending a message closes the emoji picker (it takes over the conversation
  list) so the list reappears, and clears its search filter.
- Inserting an emoji prepends a space when the draft doesn't already end in
  whitespace (still respecting the on-chain byte cap).
- Message text is selectable by click-drag (chatBodyLines / chatBodyHitTest
  mirror layoutChatBody's wrap so highlight + hit-test align with the drawn
  text); while a range is active a copy button shows at the bubble's top on the
  opposite side and copies the selected substring.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 23:04:26 -05:00
c68889d276 i18n(chat): translate chat note-buffer status strings (8 languages)
The status-bar chat-buffer indicator was hardcoded English. Add semantic keys
(chat_buffer_sending[_one]/preparing/loading/ready) to the English source and
route chatBufferStatusText() through TR()+snprintf like the other counted
strings — a singular/plural pair for the send count, %d/%d for the buffer
fill. Translate all five into de/es/fr/ja/ko/pt/ru/zh (additive JSON edits,
format specifiers preserved so the runtime overlay accepts them), and rebuild
the CJK subset font for the two new glyphs (버퍼's 퍼 U+D37C, 缓冲's 冲 U+51B2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 22:22:59 -05:00
b0a4333cdf feat(chat): pre-split note buffer for rapid sends + status + console logging
Each chat message is a shielded tx that spends a verified note, so a burst of
messages hit "insufficient verified funds" once the single note is spent. Add a
per-frame note-buffer coordinator (both variants) that keeps ~10 verified
spendable notes: it serializes sends through the one broadcast channel, counts
verified notes by block depth (lite) or a rate-limited z_listunspent scan
(full node), self-splits in the background to refill toward the target, and
queues overflow to drain as change matures — with honest Sent/Failed status
instead of the prior optimistic "Sent". Guardrails: single split in flight with
a watchdog, cooldown, and session-generation guards so a wallet switch can't
drain another wallet's queue. Surface a "Chat buffer: N/10 ready" indicator in
the status bar while the Chat tab is active, and route chat diagnostics through
the console on both variants (the full-node console now drains the shared ring).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 22:00:00 -05:00
4471f54842 feat(chat): stamp sender compose-time in message header (clock-clamped)
Carry the sender's compose time as an optional "ts" (Unix seconds) in the
plaintext header JSON that rides outside the AEAD, and prefer it as the
displayed message time so both ends show the same send time regardless of when
the tx confirms. Parse "ts" leniently. On ingest, clamp: reject a "ts"
implausibly in the future vs the receive/block time (1h skew tolerated) so a
wrong/ahead peer clock can't pin messages to the bottom of a thread; a past
compose time is fine (the note buffer may broadcast a queued message later, and
a confirmed tx's block time is always >= compose time). Tests cover the
round-trip and the future-clock clamp.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 21:59:46 -05:00
e08121af2b feat(chat): redesign message composer — radial byte gauge, autogrow, wrap
Replace the plain "N/236" byte counter with a radial wheel drawn inside the
input, right-aligned, filling green→amber→red as the message approaches the
236-byte memo cap. Vertically center the text with left padding (FramePadding),
hard-cap typing at the cap (no silent overflow), add Shift+Enter for a newline
(via an always-callback, since ImGui 1.92's Enter shortcut needs exact mods)
while plain Enter still sends, animate the input growing taller as lines are
added, and word-wrap the display instead of overflowing left. Give a hard
newline a touch more space above than a soft wrap in the message bubble.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 21:59:37 -05:00
30 changed files with 6295 additions and 216 deletions

View File

@@ -555,6 +555,7 @@ set(APP_SOURCES
src/util/platform.cpp
src/util/payment_uri.cpp
src/util/texture_loader.cpp
src/util/svg_texture.cpp
src/util/noise_texture.cpp
src/daemon/embedded_daemon.cpp
src/daemon/seed_wallet_creator.cpp

3278
libs/nanosvg/nanosvg.h Normal file

File diff suppressed because it is too large Load Diff

1472
libs/nanosvg/nanosvgrast.h Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<defs>
<style>
.cls-1 {
fill: #fff;
}
.cls-2 {
fill: #d82652;
}
</style>
</defs>
<path class="cls-2" d="M103.98,128s-6.29-24.7-18.73-34.43c-8.53-8.03-15.63-16.49-21.25-24.16-5.62,7.68-12.72,16.17-21.25,24.16-12.4,9.74-18.73,34.43-18.73,34.43-2.38-24.34,7.85-35.82,12.87-41.29,7.82-8.5,15.31-16.56,21.75-25.02-7.64-11.44-11.41-19.72-11.41-19.72-.89-3.27-3.84-6.64-3.84-6.64,6.08-8.1-1.6-16.98-1.6-16.98,5.79-5.62,6.71-10.02,8.32-18.34-1.96,22.35,4.02,39.09,13.93,54.12,9.84-15.03,15.81-31.77,13.86-54.12,1.6,8.35,2.52,12.72,8.32,18.34,0,0-7.68,8.88-1.6,16.98,0,0-2.95,3.38-3.84,6.64,0,0-3.77,8.28-11.37,19.72,6.43,8.45,13.97,16.56,21.75,25.02,4.97,5.47,15.21,16.95,12.83,41.29h0Z"/>
<g>
<path class="cls-1" d="M55.33,61.62c-3.55,4.55-7.39,8.99-11.44,13.47-5.29-4.48-11.23-5.33-11.23-5.33,22.92-7.82,2.81-15.17.28-16.31C9.28,42.78,9.1,14.78,9.1,14.78c11.51,34.15,36.42,32.3,36.42,32.3.35-.21.67-.46.92-.71,1.64,3.27,4.58,8.67,8.88,15.24h0Z"/>
<g>
<path class="cls-1" d="M68.62,40.41c-1.35,2.98-2.91,5.83-4.62,8.63-1.71-2.81-3.23-5.69-4.62-8.63,1.74-3.45,4.62-20.58,4.62-20.58,0,0,2.88,17.13,4.62,20.58Z"/>
<path class="cls-1" d="M76.01,97.93l-3.48,2.34s-.1-4.44-3.52-1.84c-.42.32-2.38,2.21-.03,4.27,0,0-4.05,4.08-4.97,8.21-.92-4.12-4.97-8.21-4.97-8.21,2.34-2.06.39-3.95-.03-4.27-3.41-2.59-3.52,1.84-3.52,1.84l-3.48-2.34c.28-3.55.1-6.68-.46-9.42,4.69-4.94,8.85-9.88,12.47-14.61,3.66,4.72,7.78,9.67,12.47,14.61-.57,2.74-.75,5.86-.46,9.42Z"/>
<path class="cls-1" d="M95.34,69.76s-5.94.85-11.23,5.33c-4.02-4.48-7.89-8.92-11.44-13.47,4.3-6.57,7.25-11.98,8.88-15.24.25.25.57.5.92.71,0,0,24.91,1.84,36.42-32.3,0,0-.18,28-23.84,38.66-2.52,1.14-22.64,8.5.28,16.31h0Z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -146,6 +146,11 @@
"chat_bubble_minimal": "Minimal",
"chat_bubble_rounded": "Abgerundet",
"chat_bubble_square": "Eckig",
"chat_buffer_loading": "Chat-Puffer: …",
"chat_buffer_preparing": "Chat-Puffer: bereite %d/%d vor…",
"chat_buffer_ready": "Chat-Puffer: %d/%d bereit",
"chat_buffer_sending": "Chat: sende %d Nachrichten…",
"chat_buffer_sending_one": "Chat: sende %d Nachricht…",
"chat_cancel": "Abbrechen",
"chat_contact_added": "Kontakt hinzugefügt benenne ihn in Kontakte um",
"chat_contact_request": "kontaktanfrage",
@@ -162,6 +167,7 @@
"chat_export_done": "Unterhaltung exportiert",
"chat_export_failed": "Exportdatei konnte nicht geschrieben werden.",
"chat_export_warn": "Speichert die entschlüsselten Nachrichten als Klartext. Bewahre die Datei sicher auf.",
"chat_filter": "Chat",
"chat_hidden_toast": "Unterhaltung ausgeblendet eine neue Nachricht holt sie zurück",
"chat_hide": "Ausblenden",
"chat_hide_hidden": "Ausgeblendete verbergen",
@@ -1521,6 +1527,7 @@
"tt_website": "Die DragonX-Website öffnen",
"tt_window_opacity": "Hintergrund-Deckkraft (niedriger = Desktop durch Fenster sichtbar)",
"tt_wizard": "Den Ersteinrichtungsassistenten erneut ausführen\\nDer Daemon wird neu gestartet",
"tx_chat_badge": "Nachricht",
"tx_confirmations": "%d Bestätigungen",
"tx_details_title": "Transaktionsdetails",
"tx_from_address": "Von Adresse:",

View File

@@ -146,6 +146,11 @@
"chat_bubble_minimal": "Mínima",
"chat_bubble_rounded": "Redondeada",
"chat_bubble_square": "Cuadrada",
"chat_buffer_loading": "Búfer de chat: …",
"chat_buffer_preparing": "Búfer de chat: preparando %d/%d…",
"chat_buffer_ready": "Búfer de chat: %d/%d listos",
"chat_buffer_sending": "Chat: enviando %d mensajes…",
"chat_buffer_sending_one": "Chat: enviando %d mensaje…",
"chat_cancel": "Cancelar",
"chat_contact_added": "Contacto añadido: renómbralo en Contactos",
"chat_contact_request": "solicitud de contacto",
@@ -162,6 +167,7 @@
"chat_export_done": "Conversación exportada",
"chat_export_failed": "No se pudo escribir el archivo de exportación.",
"chat_export_warn": "Guarda los mensajes descifrados como texto sin cifrar. Guarda el archivo de forma segura.",
"chat_filter": "Chat",
"chat_hidden_toast": "Conversación oculta: un mensaje nuevo la recupera",
"chat_hide": "Ocultar",
"chat_hide_hidden": "Ocultar ocultos",
@@ -1521,6 +1527,7 @@
"tt_website": "Abrir el sitio web de DragonX",
"tt_window_opacity": "Opacidad del fondo (menor = escritorio visible a través de la ventana)",
"tt_wizard": "Volver a ejecutar el asistente de configuración inicial\\nEl daemon será reiniciado",
"tx_chat_badge": "Mensaje",
"tx_confirmations": "%d confirmaciones",
"tx_details_title": "Detalles de Transacción",
"tx_from_address": "Dirección Origen:",

View File

@@ -146,6 +146,11 @@
"chat_bubble_minimal": "Minimale",
"chat_bubble_rounded": "Arrondie",
"chat_bubble_square": "Carrée",
"chat_buffer_loading": "Tampon de chat: …",
"chat_buffer_preparing": "Tampon de chat: préparation %d/%d…",
"chat_buffer_ready": "Tampon de chat: %d/%d prêts",
"chat_buffer_sending": "Chat: envoi de %d messages…",
"chat_buffer_sending_one": "Chat: envoi de %d message…",
"chat_cancel": "Annuler",
"chat_contact_added": "Contact ajouté — renommez-le dans Contacts",
"chat_contact_request": "demande de contact",
@@ -162,6 +167,7 @@
"chat_export_done": "Conversation exportée",
"chat_export_failed": "Impossible d'écrire le fichier d'exportation.",
"chat_export_warn": "Enregistre les messages déchiffrés en texte clair. Conservez le fichier en lieu sûr.",
"chat_filter": "Chat",
"chat_hidden_toast": "Conversation masquée — un nouveau message la fait réapparaître",
"chat_hide": "Masquer",
"chat_hide_hidden": "Masquer masqués",
@@ -1521,6 +1527,7 @@
"tt_website": "Ouvrir le site web DragonX",
"tt_window_opacity": "Opacité de l'arrière-plan (plus bas = bureau visible à travers la fenêtre)",
"tt_wizard": "Relancer l'assistant de configuration initiale\\nLe daemon sera redémarré",
"tx_chat_badge": "Message",
"tx_confirmations": "%d confirmations",
"tx_details_title": "Détails de la transaction",
"tx_from_address": "Adresse d'origine :",

View File

@@ -146,6 +146,11 @@
"chat_bubble_minimal": "ミニマル",
"chat_bubble_rounded": "角丸",
"chat_bubble_square": "角ばった",
"chat_buffer_loading": "チャットバッファ:…",
"chat_buffer_preparing": "チャットバッファ:%d/%d を準備中…",
"chat_buffer_ready": "チャットバッファ:%d/%d 準備完了",
"chat_buffer_sending": "チャット:%d 件のメッセージを送信中…",
"chat_buffer_sending_one": "チャット:%d 件のメッセージを送信中…",
"chat_cancel": "キャンセル",
"chat_contact_added": "連絡先を追加しました — 連絡先で名前を変更できます",
"chat_contact_request": "連絡リクエスト",
@@ -162,6 +167,7 @@
"chat_export_done": "会話をエクスポートしました",
"chat_export_failed": "エクスポートファイルを書き込めませんでした。",
"chat_export_warn": "復号したメッセージを平文で保存します。ファイルは安全に保管してください。",
"chat_filter": "チャット",
"chat_hidden_toast": "会話を非表示にしました — 新しいメッセージが届くと再表示されます",
"chat_hide": "非表示",
"chat_hide_hidden": "非表示を隠す",
@@ -1521,6 +1527,7 @@
"tt_website": "DragonX ウェブサイトを開く",
"tt_window_opacity": "背景の不透明度(低い = デスクトップがウィンドウ越しに見える)",
"tt_wizard": "初期セットアップウィザードを再実行\\nデーモンは再起動されます",
"tx_chat_badge": "メッセージ",
"tx_confirmations": "%d 確認",
"tx_details_title": "取引の詳細",
"tx_from_address": "送信元アドレス:",

View File

@@ -146,6 +146,11 @@
"chat_bubble_minimal": "미니멀",
"chat_bubble_rounded": "둥근",
"chat_bubble_square": "각진",
"chat_buffer_loading": "채팅 버퍼: …",
"chat_buffer_preparing": "채팅 버퍼: %d/%d 준비 중…",
"chat_buffer_ready": "채팅 버퍼: %d/%d 준비됨",
"chat_buffer_sending": "채팅: 메시지 %d개 보내는 중…",
"chat_buffer_sending_one": "채팅: 메시지 %d개 보내는 중…",
"chat_cancel": "취소",
"chat_contact_added": "연락처 추가됨 — 연락처에서 이름을 변경하세요",
"chat_contact_request": "연락 요청",
@@ -162,6 +167,7 @@
"chat_export_done": "대화를 내보냈습니다",
"chat_export_failed": "내보내기 파일을 쓸 수 없습니다.",
"chat_export_warn": "복호화된 메시지를 일반 텍스트로 저장합니다. 파일을 안전하게 보관하세요.",
"chat_filter": "채팅",
"chat_hidden_toast": "대화를 숨겼습니다 — 새 메시지가 오면 다시 표시됩니다",
"chat_hide": "숨기기",
"chat_hide_hidden": "숨김 접기",
@@ -1521,6 +1527,7 @@
"tt_website": "DragonX 웹사이트 열기",
"tt_window_opacity": "배경 불투명도 (낮을수록 = 창을 통해 바탕 화면이 보임)",
"tt_wizard": "초기 설정 마법사 다시 실행\\n데몬이 재시작됩니다",
"tx_chat_badge": "메시지",
"tx_confirmations": "%d 확인",
"tx_details_title": "거래 상세",
"tx_from_address": "보낸 주소:",

View File

@@ -146,6 +146,11 @@
"chat_bubble_minimal": "Mínimo",
"chat_bubble_rounded": "Arredondado",
"chat_bubble_square": "Quadrado",
"chat_buffer_loading": "Buffer de chat: …",
"chat_buffer_preparing": "Buffer de chat: preparando %d/%d…",
"chat_buffer_ready": "Buffer de chat: %d/%d prontos",
"chat_buffer_sending": "Chat: enviando %d mensagens…",
"chat_buffer_sending_one": "Chat: enviando %d mensagem…",
"chat_cancel": "Cancelar",
"chat_contact_added": "Contato adicionado — renomeie em Contatos",
"chat_contact_request": "solicitação de contato",
@@ -162,6 +167,7 @@
"chat_export_done": "Conversa exportada",
"chat_export_failed": "Não foi possível gravar o arquivo de exportação.",
"chat_export_warn": "Salva as mensagens descriptografadas como texto simples. Guarde o arquivo com segurança.",
"chat_filter": "Chat",
"chat_hidden_toast": "Conversa ocultada — uma nova mensagem a traz de volta",
"chat_hide": "Ocultar",
"chat_hide_hidden": "Ocultar ocultas",
@@ -1521,6 +1527,7 @@
"tt_website": "Abrir o site do DragonX",
"tt_window_opacity": "Opacidade do fundo (menor = área de trabalho visível através da janela)",
"tt_wizard": "Executar novamente o assistente de configuração inicial\\nO daemon será reiniciado",
"tx_chat_badge": "Mensagem",
"tx_confirmations": "%d confirmações",
"tx_details_title": "Detalhes da Transação",
"tx_from_address": "Endereço de Origem:",

View File

@@ -146,6 +146,11 @@
"chat_bubble_minimal": "Минимальный",
"chat_bubble_rounded": "Скруглённый",
"chat_bubble_square": "Прямоугольный",
"chat_buffer_loading": "Буфер чата: …",
"chat_buffer_preparing": "Буфер чата: подготовка %d/%d…",
"chat_buffer_ready": "Буфер чата: %d/%d готово",
"chat_buffer_sending": "Чат: отправка %d сообщений…",
"chat_buffer_sending_one": "Чат: отправка %d сообщения…",
"chat_cancel": "Отмена",
"chat_contact_added": "Контакт добавлен — переименуйте его в Контактах",
"chat_contact_request": "запрос контакта",
@@ -162,6 +167,7 @@
"chat_export_done": "Разговор экспортирован",
"chat_export_failed": "Не удалось записать файл экспорта.",
"chat_export_warn": "Сохраняет расшифрованные сообщения в виде обычного текста. Храните файл в надёжном месте.",
"chat_filter": "Чат",
"chat_hidden_toast": "Разговор скрыт — новое сообщение вернёт его",
"chat_hide": "Скрыть",
"chat_hide_hidden": "Скрыть скрытые",
@@ -1521,6 +1527,7 @@
"tt_website": "Открыть сайт DragonX",
"tt_window_opacity": "Непрозрачность фона (ниже = рабочий стол виден сквозь окно)",
"tt_wizard": "Повторно запустить мастер начальной настройки\\nДемон будет перезапущен",
"tx_chat_badge": "Сообщение",
"tx_confirmations": "%d подтверждений",
"tx_details_title": "Детали транзакции",
"tx_from_address": "Адрес отправителя:",

View File

@@ -146,6 +146,11 @@
"chat_bubble_minimal": "极简",
"chat_bubble_rounded": "圆角",
"chat_bubble_square": "方形",
"chat_buffer_loading": "聊天缓冲:…",
"chat_buffer_preparing": "聊天缓冲:正在准备 %d/%d…",
"chat_buffer_ready": "聊天缓冲:%d/%d 已就绪",
"chat_buffer_sending": "聊天:正在发送 %d 条消息…",
"chat_buffer_sending_one": "聊天:正在发送 %d 条消息…",
"chat_cancel": "取消",
"chat_contact_added": "已添加联系人——可在联系人中重命名",
"chat_contact_request": "联系人请求",
@@ -162,6 +167,7 @@
"chat_export_done": "会话已导出",
"chat_export_failed": "无法写入导出文件。",
"chat_export_warn": "将解密后的消息保存为纯文本。请妥善保管该文件。",
"chat_filter": "聊天",
"chat_hidden_toast": "会话已隐藏——收到新消息后会重新显示",
"chat_hide": "隐藏",
"chat_hide_hidden": "收起已隐藏",
@@ -1521,6 +1527,7 @@
"tt_website": "打开 DragonX 网站",
"tt_window_opacity": "背景不透明度(越低 = 桌面透过窗口可见)",
"tt_wizard": "重新运行初始设置向导\\n守护进程将被重启",
"tx_chat_badge": "消息",
"tx_confirmations": "%d 次确认",
"tx_details_title": "交易详情",
"tx_from_address": "发送地址:",

View File

@@ -73,6 +73,9 @@
#include "util/text_format.h"
#include "util/payment_uri.h"
#include "util/texture_loader.h"
#include "util/svg_texture.h"
#include "ui/material/colors.h"
#include "logo_dragonx_svg.h"
#include "util/bootstrap.h"
#include "util/secure_vault.h"
#include "resources/embedded_resources.h"
@@ -735,20 +738,40 @@ void App::update()
// HushChat (lite): harvest chat memos from the refreshed transactions and thread them
// (no-op when the feature is off or no identity; the store dedups across refreshes).
ingestLiteChatMemos(liteModel);
// Chat note-buffer: recompute the verified/maturing self-note estimates from the fresh model.
refreshChatNoteBudget(liteModel);
}
// Deliver a completed async send/shield result to the waiting send_tab callback.
// Deliver a completed async send/shield result. Route by the in-flight op that owns the single
// broadcast channel: a user Send-tab send goes to lite_send_callback_; a chat send / contact
// request resolves its echo (real status — not the old optimistic "Sent"); a note-buffer split
// just logs. Because the controller runs one broadcast at a time, inflight_op_ correlates the
// one global result with no txid matching.
wallet::LiteBroadcastResult broadcast;
if (lite_wallet_->takeBroadcastResult(broadcast)) {
// Mirror failures into the lite Console (copyable) in addition to the toast the send UI
// shows — transient toasts are easy to miss and impossible to copy.
if (!broadcast.ok)
wallet::liteLog("Send/shield failed: " + broadcast.error);
const LiteInflightOp finished = inflight_op_;
inflight_op_ = LiteInflightOp{}; // channel is free again
switch (finished.kind) {
case LiteOpKind::ChatSend:
case LiteOpKind::ContactRequest:
onChatBroadcastResult(finished, broadcast.ok, broadcast.error);
break;
case LiteOpKind::Split:
// Buffer split done; the next refresh re-counts the new (maturing) notes.
break;
case LiteOpKind::UserSend:
case LiteOpKind::None:
default:
if (lite_send_callback_) {
lite_send_callback_(broadcast.ok, broadcast.ok ? broadcast.txid : broadcast.error);
lite_send_callback_ = nullptr;
}
break;
}
}
// Startup lock screen: once the first refresh reveals the (auto-opened) wallet is
// encrypted, prompt to unlock if it's locked. Soft by design — balances stay viewable via
// viewing keys while locked; only spending needs the passphrase, so the user may dismiss
@@ -765,6 +788,13 @@ void App::update()
// it can act; compiled away when the feature is off (constexpr gate inside).
maybeProvisionChatIdentity();
// Chat note-buffer coordinator (BOTH variants). Full-node only: refresh the per-note counts via a
// rate-limited z_listunspent worker scan (lite gets its counts from the refresh model, above). Then
// pump: drain queued chat sends against verified notes and build/refill the buffer during idle. Both
// self-gate to no-ops unless chat is engaged; the pump serializes to one send outstanding at a time.
refreshChatNoteBudgetNode();
pumpChatNoteBuffer();
// One-time reminder to back up the wallet's seed phrase (mnemonic wallets only).
maybeRemindSeedBackup();
@@ -1326,6 +1356,103 @@ void App::handleGlobalShortcuts()
}
}
// Load (and recolor per theme) the DragonX header logo texture. Called at the top of render() — BEFORE
// the first-run-wizard / lock-screen paths — so every screen shows the mark. The SVG is recolored to the
// theme accent, so it re-rasterizes whenever the accent or the dark↔light variant changes.
void App::ensureLogoTexture()
{
const bool wantDark = ui::material::IsDarkTheme();
const ImU32 logoAccent = ui::material::Primary();
if (logo_loaded_ && wantDark == logo_is_dark_variant_ && logoAccent == logo_accent_)
return; // already current
logo_loaded_ = true;
logo_is_dark_variant_ = wantDark;
logo_accent_ = logoAccent;
// The SVG's white highlight (detail) stays white on dark skins, but darkens to the theme's on-surface
// colour on light skins so it doesn't wash out against a light card/background.
const ImU32 detailCol = wantDark ? IM_COL32(255, 255, 255, 255) : ui::material::OnSurface();
// ":drgx:" custom chat emoji — themed to the accent like the logo (re-rasterized on theme change).
if (drgx_emoji_tex_) util::DestroyTexture(drgx_emoji_tex_);
drgx_emoji_tex_ = 0; drgx_emoji_w_ = 0; drgx_emoji_h_ = 0;
util::LoadTextureFromSvg(embedded::kLogoDragonXSvg, 96, logoAccent, detailCol,
&drgx_emoji_tex_, &drgx_emoji_w_, &drgx_emoji_h_);
if (logo_tex_) util::DestroyTexture(logo_tex_); // free the previous texture before replacing
logo_tex_ = 0; logo_w_ = 0; logo_h_ = 0;
// Coin/currency icon (the big DragonX mark on the balance card) — the SAME themed SVG recolored to
// the accent, rasterized here so it changes with the skin too (done first, since the header block
// below early-returns on success). Falls back to the PNG coin icon if rasterization fails.
if (coin_logo_tex_) util::DestroyTexture(coin_logo_tex_);
coin_logo_tex_ = 0; coin_logo_w_ = 0; coin_logo_h_ = 0;
coin_logo_loaded_ = true;
if (!util::LoadTextureFromSvg(embedded::kLogoDragonXSvg, 128, logoAccent, detailCol,
&coin_logo_tex_, &coin_logo_w_, &coin_logo_h_)) {
auto coinElem = ui::schema::UI().drawElement("components.main-window", "coin-icon");
auto cit = coinElem.extraColors.find("icon");
std::string coinFile = (cit != coinElem.extraColors.end() && !cit->second.empty())
? cit->second : "logos/logo_dragonx_128.png";
std::string coinPath = util::getExecutableDirectory() + "/res/img/" + coinFile;
std::error_code coinEc;
if (!(std::filesystem::exists(coinPath, coinEc) &&
util::LoadTextureFromFile(coinPath.c_str(), &coin_logo_tex_, &coin_logo_w_, &coin_logo_h_))) {
std::string coinBasename = std::filesystem::path(coinFile).filename().string();
const auto* coinRes = resources::getEmbeddedResource(coinBasename);
if (coinRes && coinRes->data && coinRes->size > 0)
util::LoadTextureFromMemory(coinRes->data, coinRes->size, &coin_logo_tex_, &coin_logo_w_, &coin_logo_h_);
}
}
// 0) DragonX mark — rasterize the embedded SVG recolored to the theme (body = accent, detail = white)
// at ~2x the 128px viewBox for crisp downscaling. This is the branding on every skin; the per-skin
// PNG path below is only a fallback if rasterization ever fails.
if (util::LoadTextureFromSvg(embedded::kLogoDragonXSvg, 256, logoAccent,
detailCol, &logo_tex_, &logo_w_, &logo_h_)) {
DEBUG_LOGF("Rendered DragonX SVG logo (%dx%d, accent %08X)\n", logo_w_, logo_h_, logoAccent);
return;
}
// 1) Fallback — theme-override logo from the active skin
const auto* activeSkin = ui::schema::SkinManager::instance().findById(
ui::schema::SkinManager::instance().activeSkinId());
std::string logoPath;
if (activeSkin && !activeSkin->logoPath.empty()) {
logoPath = activeSkin->logoPath;
} else {
// 2) Read icon filename from ui.toml (dark/light variant)
auto iconElem = ui::schema::UI().drawElement("components.main-window", "header-icon");
const char* iconKey = wantDark ? "icon-dark" : "icon-light";
auto it = iconElem.extraColors.find(iconKey);
std::string iconFile;
if (it != iconElem.extraColors.end() && !it->second.empty())
iconFile = it->second;
else
iconFile = wantDark ? "logos/logo_ObsidianDragon_dark.png" : "logos/logo_ObsidianDragon_light.png";
logoPath = util::getExecutableDirectory() + "/res/img/" + iconFile;
}
// Only attempt the disk read when the file is actually present (dev build / theme drop-in). The
// portable single-file build has no res/img/ beside it, so skip straight to the embedded copy.
std::error_code logoEc;
if (std::filesystem::exists(logoPath, logoEc) &&
util::LoadTextureFromFile(logoPath.c_str(), &logo_tex_, &logo_w_, &logo_h_)) {
DEBUG_LOGF("Loaded header logo from %s (%dx%d)\n", logoPath.c_str(), logo_w_, logo_h_);
} else {
std::string embeddedName = std::filesystem::path(logoPath).filename().string();
const auto* logoRes = resources::getEmbeddedResource(embeddedName);
if (!logoRes || !logoRes->data || logoRes->size == 0)
logoRes = resources::getEmbeddedResource(resources::RESOURCE_LOGO);
if (logoRes && logoRes->data && logoRes->size > 0) {
if (util::LoadTextureFromMemory(logoRes->data, logoRes->size, &logo_tex_, &logo_w_, &logo_h_))
DEBUG_LOGF("Loaded header logo from embedded: %s (%dx%d)\n", embeddedName.c_str(), logo_w_, logo_h_);
else
DEBUG_LOGF("Note: Failed to decode embedded logo (text-only header)\n");
} else {
DEBUG_LOGF("Note: Header logo not found at %s (text-only header)\n", logoPath.c_str());
}
}
}
void App::render()
{
// Advance the screenshot sweep FIRST — before the first-run-wizard early-return below — so the
@@ -1333,6 +1460,9 @@ void App::render()
// (Pins current_page_ too, before the sidebar reads it further down.)
updateScreenshotSweep();
// DragonX logo — load/recolor before the wizard/lock early-returns so every screen shows it.
ensureLogoTexture();
// First-run wizard gate — blocks all normal UI
if (wizard_phase_ != WizardPhase::None && wizard_phase_ != WizardPhase::Done) {
renderFirstRunWizard();
@@ -1533,94 +1663,9 @@ void App::render()
sbStatus.miningActive = state_.mining.generate || state_.pool_mining.xmrig_running;
sbStatus.chatUnreadCount = chatUnreadCount(); // unread badge on the Chat nav item (Q1)
// Load logo texture lazily on first frame (or after theme change)
// Also reload when dark↔light mode changes so the correct variant shows
{
bool wantDark = ui::material::IsDarkTheme();
if (!logo_loaded_ || (wantDark != logo_is_dark_variant_)) {
logo_loaded_ = true;
logo_is_dark_variant_ = wantDark;
logo_tex_ = 0; logo_w_ = 0; logo_h_ = 0;
// (DragonX logo is loaded at the top of render() via ensureLogoTexture().)
// 1) Check for theme-override logo from active skin
const auto* activeSkin = ui::schema::SkinManager::instance().findById(
ui::schema::SkinManager::instance().activeSkinId());
std::string logoPath;
if (activeSkin && !activeSkin->logoPath.empty()) {
logoPath = activeSkin->logoPath;
} else {
// 2) Read icon filename from ui.toml (dark/light variant)
auto iconElem = ui::schema::UI().drawElement("components.main-window", "header-icon");
const char* iconKey = wantDark ? "icon-dark" : "icon-light";
auto it = iconElem.extraColors.find(iconKey);
std::string iconFile;
if (it != iconElem.extraColors.end() && !it->second.empty()) {
iconFile = it->second;
} else {
// Fallback filenames
iconFile = wantDark ? "logos/logo_ObsidianDragon_dark.png" : "logos/logo_ObsidianDragon_light.png";
}
logoPath = util::getExecutableDirectory() + "/res/img/" + iconFile;
}
// Only attempt the disk read when the file is actually present (dev build / theme drop-in).
// The portable single-file build has no res/img/ beside it, so skip straight to the
// embedded copy instead of logging a spurious "failed to read".
std::error_code logoEc;
if (std::filesystem::exists(logoPath, logoEc) &&
util::LoadTextureFromFile(logoPath.c_str(), &logo_tex_, &logo_w_, &logo_h_)) {
DEBUG_LOGF("Loaded header logo from %s (%dx%d)\n", logoPath.c_str(), logo_w_, logo_h_);
} else {
// Try embedded data fallback — use actual filename from path
// so light/dark variants resolve correctly on Windows single-file
std::string embeddedName = std::filesystem::path(logoPath).filename().string();
const auto* logoRes = resources::getEmbeddedResource(embeddedName);
if (!logoRes || !logoRes->data || logoRes->size == 0) {
// Final fallback: try the default dark logo constant
logoRes = resources::getEmbeddedResource(resources::RESOURCE_LOGO);
}
if (logoRes && logoRes->data && logoRes->size > 0) {
if (util::LoadTextureFromMemory(logoRes->data, logoRes->size, &logo_tex_, &logo_w_, &logo_h_)) {
DEBUG_LOGF("Loaded header logo from embedded: %s (%dx%d)\n", embeddedName.c_str(), logo_w_, logo_h_);
} else {
DEBUG_LOGF("Note: Failed to decode embedded logo (text-only header)\n");
}
} else {
DEBUG_LOGF("Note: Header logo not found at %s (text-only header)\n", logoPath.c_str());
}
}
}
}
// Load coin logo texture lazily (DragonX currency icon for balance tab)
if (!coin_logo_loaded_) {
coin_logo_loaded_ = true;
coin_logo_tex_ = 0; coin_logo_w_ = 0; coin_logo_h_ = 0;
// Read coin icon filename from ui.toml
auto coinElem = ui::schema::UI().drawElement("components.main-window", "coin-icon");
auto cit = coinElem.extraColors.find("icon");
std::string coinFile = (cit != coinElem.extraColors.end() && !cit->second.empty())
? cit->second : "logos/logo_dragonx_128.png";
std::string coinPath = util::getExecutableDirectory() + "/res/img/" + coinFile;
std::error_code coinEc;
if (std::filesystem::exists(coinPath, coinEc) &&
util::LoadTextureFromFile(coinPath.c_str(), &coin_logo_tex_, &coin_logo_w_, &coin_logo_h_)) {
DEBUG_LOGF("Loaded coin logo from %s (%dx%d)\n", coinPath.c_str(), coin_logo_w_, coin_logo_h_);
} else {
// Try embedded resource fallback (Windows single-file distribution)
std::string coinBasename = std::filesystem::path(coinFile).filename().string();
const auto* coinRes = resources::getEmbeddedResource(coinBasename);
if (coinRes && coinRes->data && coinRes->size > 0) {
if (util::LoadTextureFromMemory(coinRes->data, coinRes->size, &coin_logo_tex_, &coin_logo_w_, &coin_logo_h_)) {
DEBUG_LOGF("Loaded coin logo from embedded: %s (%dx%d)\n", coinBasename.c_str(), coin_logo_w_, coin_logo_h_);
} else {
DEBUG_LOGF("Note: Failed to decode embedded coin logo\n");
}
} else {
DEBUG_LOGF("Note: Coin logo not found at %s\n", coinPath.c_str());
}
}
}
// (coin logo is loaded/themed at the top of render() via ensureLogoTexture().)
if (logo_tex_ != 0) {
sbStatus.logoTexID = logo_tex_;
@@ -2324,17 +2369,32 @@ void App::renderStatusBar()
// Connection / daemon status sits to the left of the version string
// with a small gap.
float gap = sbSectionGap;
float occupiedX = versionX; // leftmost X used by the version + connection status so far
if (!connection_status_.empty() && connection_status_ != "Connected") {
float statusW = ImGui::CalcTextSize(connection_status_.c_str()).x;
float statusX = versionX - statusW - gap;
ImGui::SameLine(statusX);
ImGui::TextDisabled("%s", connection_status_.c_str());
occupiedX = statusX;
} else if (!daemon_status_.empty() && daemon_status_.find("Error") != std::string::npos) {
const char* errText = TR("sb_daemon_not_found");
float statusW = ImGui::CalcTextSize(errText).x;
float statusX = versionX - statusW - gap;
ImGui::SameLine(statusX);
ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.5f, 1.0f), "%s", errText);
occupiedX = statusX;
}
// Chat note-buffer status — only while the Chat tab is active. Sits left of the version/connection
// block. Surfaces the buffer filling/draining (and doubles as a diagnostic for send readiness).
if (current_page_ == ui::NavPage::Chat) {
const std::string cb = chatBufferStatusText();
if (!cb.empty()) {
float cbW = ImGui::CalcTextSize(cb.c_str()).x;
float cbX = occupiedX - cbW - gap;
ImGui::SameLine(cbX);
ImGui::TextUnformatted(cb.c_str());
}
}
// Version always at far right
@@ -2387,12 +2447,15 @@ void App::reloadThemeImages(const std::string& bgPath, const std::string& logoPa
gradient_tex_ = 0;
}
// Reset logo loaded flags — will reload on next render frame
// Reset logo loaded flags — will reload on next render frame (ensureLogoTexture re-rasterizes the
// SVG for the new theme). Free the current texture first so a theme switch doesn't leak it.
if (logo_tex_) util::DestroyTexture(logo_tex_);
logo_loaded_ = false;
logo_tex_ = 0;
logo_w_ = 0;
logo_h_ = 0;
coin_logo_loaded_ = false;
if (coin_logo_tex_) util::DestroyTexture(coin_logo_tex_);
coin_logo_tex_ = 0;
coin_logo_w_ = 0;
coin_logo_h_ = 0;

View File

@@ -13,6 +13,7 @@
#include <chrono>
#include <unordered_map>
#include <unordered_set>
#include <deque>
#include <nlohmann/json_fwd.hpp>
#include "data/transaction_history_cache.h"
#include "data/address_book.h"
@@ -478,6 +479,10 @@ public:
// Coin logo texture accessor (DragonX currency icon for balance tab)
ImTextureID getCoinLogoTexture() const { return coin_logo_tex_; }
// DragonX custom chat emoji (the ":drgx:" shortcode) — the mark recolored to the theme accent (like
// the logo), re-rasterized on theme change. Used by the emoji picker tile + inline in chat bubbles.
ImTextureID getDrgxEmojiTexture() const { return drgx_emoji_tex_; }
/**
* @brief Reload theme images (background gradient + logo) from new paths
* @param bgPath Path to background image override (empty = use default)
@@ -485,6 +490,10 @@ public:
*/
void reloadThemeImages(const std::string& bgPath, const std::string& logoPath);
// Load / recolor-per-theme the DragonX header logo (SVG rasterized to the theme accent). Called at
// the top of render() so the wizard, lock screen, and main header all show it.
void ensureLogoTexture();
// Wizard / first-run
WizardPhase getWizardPhase() const { return wizard_phase_; }
bool isFirstRun() const;
@@ -605,10 +614,13 @@ private:
// the recipient `to`/`amount`/`memo`/`fee` used to record the optimistic pending-send row).
// When markFeeGapRetry is set, the returned opid is recorded in send_feegap_retried_opids_ so a
// retry of a retry is reported as a real error.
// background=true (autonomous chat sends / note-buffer splits): keep the single-flight + opid
// accounting but DON'T raise the global "transaction in progress" UI (status is on the chat message).
void submitZSendMany(const std::string& from, const std::string& to, double amount, double fee,
const std::string& memo, const nlohmann::json& recipients,
const char* traceLabel, bool markFeeGapRetry,
std::function<void(bool, const std::string&)> callback);
std::function<void(bool, const std::string&)> callback,
bool background = false);
void markPendingSendTransactionSucceeded(const std::string& opid,
const std::string& txid);
void removePendingSendTransactions(const std::vector<std::string>& opids,
@@ -695,6 +707,72 @@ private:
// thread is viewed (markChatConversationSeen); wiped in resetChatSession so unread doesn't leak across
// wallets. In-memory only (resets on app restart).
std::map<std::string, std::int64_t> chat_seen_watermark_;
// ── 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
// rapid sends run out of verified funds. We keep a buffer of ~kChatBufferTarget small self-notes so a
// burst of messages each spends a separate verified note, refilled from change + background self-
// splits. Chat sends, self-splits and user sends share the wallet's single send channel; inflight_op_
// + (lite) lite_send_callback_ / (full node) send_submissions_in_flight_+pending_opids_ serialize them
// so exactly one send is ever outstanding. Implemented in app_network.cpp; pumped from update().
enum class LiteOpKind { None, ChatSend, ContactRequest, Split, UserSend };
struct LiteInflightOp {
LiteOpKind kind = LiteOpKind::None;
std::string echoLocalId; // ChatSend/ContactRequest: the echo to resolve when it completes
int sessionGen = 0; // chat_session_generation_ snapshot at submit (stale-guard)
double submittedAt = 0.0;
};
struct QueuedChatOp {
LiteOpKind kind = LiteOpKind::ChatSend;
chat::OutgoingChatMemos memos; // kept so a transient-funds retry re-broadcasts, never recomposes
std::string echoLocalId;
int sessionGen = 0;
int retries = 0;
};
LiteInflightOp inflight_op_; // the single chat/split op currently on the send channel
std::deque<QueuedChatOp> chat_send_queue_; // chat/contact sends awaiting a free channel + verified note
// Note-availability estimate between refreshes/scans: reset from a fresh count, decremented on each chat
// submit (the count lags a spend by a cycle, but the wallet still picks a fresh note per send). Zeroed on
// a transient-funds failure so we stop draining until the next refresh/scan restores the truth.
int chat_verified_note_budget_ = 0;
int chat_pipeline_note_count_ = 0; // verified + maturing self-notes (drives shouldSplit)
std::uint64_t chat_verified_shielded_zat_ = 0; // verified shielded balance (split affordability)
bool chat_note_model_seen_ = false; // saw a refresh/scan carrying per-note visibility
// Single-split-in-flight guard: a self-split's OUTPUT notes are invisible until mined (~1 block), far
// longer than any wall-clock cooldown — so we permit only ONE outstanding split and clear the flag when
// the pipeline recovers (outputs mined) or a watchdog expires (a split that never mines mustn't wedge
// refill forever). Prevents runaway splitting that would drain balance into fees.
bool chat_split_outstanding_ = false;
double chat_split_submitted_at_ = 0.0; // ImGui time the outstanding split was submitted (watchdog)
// Full-node only: a coordinator-owned z_listunspent worker scan feeds the per-note counts (the shared
// balance poll discards per-note data). Mirrors chat_fast_scan_in_flight_.
bool chat_note_scan_in_flight_ = false;
double chat_note_scan_last_ = 0.0; // ImGui time of the last note scan (rate limit)
// Most-recent chain tip, cached across refreshes: a lite refresh model that carries spendableOutputs
// may NOT carry sync status that same cycle (tolerated partial refresh), so verifiedSelfNoteCount reads
// this cache rather than requiring the current model to have both — else the budget flickers to 0.
std::int64_t chat_last_chain_height_ = 0;
int chat_fast_scan_last_seen_ = -1; // dedup: last memo-note count logged by the 0-conf scan
// Coordinator helpers (both variants unless noted; see app_network.cpp).
void refreshChatNoteBudget(const wallet::LiteWalletAppRefreshModel& model); // lite: recompute caches on a fresh model
void refreshChatNoteBudgetNode(); // full node: rate-limited z_listunspent worker scan
void pumpChatNoteBuffer(); // per-frame: drain the queue / build the buffer
int verifiedSelfNoteCount(const wallet::LiteWalletAppRefreshModel& model); // lite
int pipelineSelfNoteCount(const wallet::LiteWalletAppRefreshModel& model); // lite
bool shouldSplitChatBuffer();
bool broadcastSelfSplitLite(int noteCount); // lite: self-send minting noteCount reply-address notes
bool broadcastSelfSplitNode(int noteCount); // full node: z_sendmany self-send minting noteCount notes
void enqueueChatSend(LiteOpKind kind, const chat::OutgoingChatMemos& memos, const std::string& echoLocalId);
void onChatBroadcastResult(const LiteInflightOp& op, bool ok, const std::string& error);
static bool isTransientVerifiedFundsError(const std::string& error);
int chatConfsRequired() const; // verified-note confs threshold: 5 (lite) / 1 (full node)
public:
// Status-bar summary of the chat note buffer (empty when not applicable). Shown while the Chat tab is
// active so the buffer's state (ready / building / sending) is visible.
std::string chatBufferStatusText();
private:
public:
// Total unread incoming chat messages across all conversations (for the sidebar badge). 0 when the
// feature is off / no identity.
@@ -1010,6 +1088,9 @@ private:
int logo_h_ = 0;
bool logo_loaded_ = false;
bool logo_is_dark_variant_ = true; // tracks which variant is currently loaded
ImU32 logo_accent_ = 0; // theme accent the SVG logo was last rasterized with (re-render on change)
ImTextureID drgx_emoji_tex_ = 0; // ":drgx:" custom chat emoji (themed to the accent, like the logo)
int drgx_emoji_w_ = 0, drgx_emoji_h_ = 0;
// Coin logo texture (DragonX currency icon, separate from wallet branding)
ImTextureID coin_logo_tex_ = 0;

View File

@@ -40,6 +40,7 @@
#include "config/settings.h"
#include "wallet/lite_wallet_controller.h" // lite send/new-address routing
#include "wallet/lite_wallet_state_mapper.h" // LiteWalletAppRefreshModel for lite chat harvest
#include "wallet/lite_diagnostics.h" // liteLog — chat note-buffer coordinator diagnostics
#include "config/version.h"
#include "daemon/daemon_controller.h"
#include "daemon/embedded_daemon.h"
@@ -629,6 +630,11 @@ void App::onDisconnected(const std::string& reason)
consecutive_core_failures_ = 0;
send_progress_active_ = false;
send_submissions_in_flight_ = 0;
// Release the chat note-buffer's single-split guard on a plain disconnect (which doesn't run
// resetChatSession) so a re-split isn't suppressed until the 1200s watchdog after reconnect.
chat_split_outstanding_ = false;
chat_split_submitted_at_ = 0.0;
inflight_op_ = LiteInflightOp{}; // the in-flight chat send's callback was just fired above (Failed)
network_refresh_.resetJobs();
rescan_status_poll_in_progress_ = false;
opid_poll_in_progress_ = false;
@@ -2756,6 +2762,25 @@ void App::provisionChatIdentityFromSecret(std::string secret)
// local — peers only ever exchange public keys).
void App::resetChatSession()
{
// Lite send channel — reset UNCONDITIONALLY (independent of the chat feature flag; user sends set
// inflight_op_/lite_send_callback_ even with chat off). Clear the note-buffer coordinator so wallet A's
// queued sends can't drain under wallet B, and DON'T orphan a user Send callback across a controller
// rebuild: rebuildLiteWallet(force) destroys the controller holding the in-flight send, so its result
// slot dies with it and the callback would hang the Send dialog forever. Fire the orphaned callback on a
// moved-out copy AFTER the channel state is reset, so a re-entrant sendTransaction stays clean.
auto orphanedSend = std::move(lite_send_callback_);
lite_send_callback_ = nullptr;
chat_send_queue_.clear();
inflight_op_ = LiteInflightOp{};
chat_verified_note_budget_ = 0;
chat_pipeline_note_count_ = 0;
chat_verified_shielded_zat_ = 0;
chat_note_model_seen_ = false;
chat_split_outstanding_ = false;
chat_split_submitted_at_ = 0.0;
chat_note_scan_in_flight_ = false; // a stale in-flight note scan is dropped by its scanGen guard
if (orphanedSend) orphanedSend(false, "Wallet/server changed — send aborted");
if (!chat::hushChatFeatureEnabledAtBuild()) return; // constexpr — folds away in OFF builds
// Drop identity + decrypted plaintext in RAM, lock the seed-encrypted DB, re-arm provisioning.
chat_service_.clearIdentity();
@@ -2955,18 +2980,19 @@ bool App::broadcastChatMemos(const chat::OutgoingChatMemos& memos, const std::st
const auto sessionGen = chat_session_generation_;
if (lite_wallet_) {
const bool ok = broadcastChatMemosLite(memos);
// The lite backend has no per-send completion callback here; resolve optimistically on a
// successful queue (its own broadcast log surfaces a later failure).
if (ok && sessionGen == chat_session_generation_)
chat_service_.resolveOutgoing(echoLocalId, chat::ChatDelivery::Sent);
return ok;
// Lite chat is owned by the note-buffer coordinator (enqueue + drain + resolve from the real
// broadcast result). Route any caller here through the queue rather than resolving optimistically
// (the old code marked a merely-queued send "Sent", masking real failures). Kept for safety —
// the send/Retry paths call enqueueChatSend directly and don't hit this branch.
(void)sessionGen;
enqueueChatSend(LiteOpKind::ChatSend, memos, echoLocalId);
return true;
}
if (!state_.connected || !rpc_ || !worker_) {
ui::Notifications::instance().error(TR("chat_toast_not_connected"));
return false;
}
// Coordinator-owned primitive (pumpChatNoteBuffer): the pump already gates on connected/unlocked and a
// verified-note budget, so these refusals are belt-and-suspenders. Return false SILENTLY (no per-frame
// toast) — the pump leaves the message queued ("Sending") and retries when the guard clears.
if (!state_.connected || !rpc_ || !worker_) return false;
// Chat moves 0 value, and dragonxd REJECTS a 0-value tx whose fee exceeds the default miners fee
// (0.0001) — so pin chat to exactly kChatMinFeeDrgx, independent of the user's default-fee setting
@@ -2976,10 +3002,7 @@ bool App::broadcastChatMemos(const chat::OutgoingChatMemos& memos, const std::st
// Pay from a z-address that can actually cover the fee. The memo still advertises the identity
// reply address, so the peer replies to the right place regardless of which note paid.
const std::string from = chatPayFromZaddr(fee);
if (from.empty()) {
ui::Notifications::instance().error(TR("chat_toast_need_funds"));
return false;
}
if (from.empty()) return false; // no fundable note yet — pump keeps it queued, retries after a scan
// Full-node: each memo needs the daemon's "utf8:" prefix (raw JSON/hex is otherwise rejected).
const auto outputs = chat::chatSendOutputs(memos, /*utf8Prefix=*/true);
@@ -2994,14 +3017,20 @@ bool App::broadcastChatMemos(const chat::OutgoingChatMemos& memos, const std::st
// markFeeGapRetry=true is deliberate: it suppresses the fee-gap auto-retry, which rebuilds a
// SINGLE-recipient tx from the scalar to/amount/memo and would drop the second (payload) output.
// The callback flips the echo to Sent/Failed once the async op resolves — real delivery status.
// This is now the note-buffer coordinator's full-node submit primitive (called only from the pump).
// The terminal callback (fired once by the opid poller) releases the send channel and routes the
// result through onChatBroadcastResult, so a transient "Insufficient shielded funds" requeues instead
// of hard-failing. The sessionGen guard drops a stale wallet's echo AND protects the inflight_op_
// clear from a callback landing after a wallet switch (which would otherwise clear a NEW op's token).
submitZSendMany(from, memos.recipientZaddr, 0.0, fee, /*memo*/"", recipients,
"HushChat / broadcast", /*markFeeGapRetry*/ true,
[this, echoLocalId, sessionGen](bool ok, const std::string& /*result*/) {
if (sessionGen != chat_session_generation_) return; // wallet locked/switched — stale
chat_service_.resolveOutgoing(echoLocalId,
ok ? chat::ChatDelivery::Sent : chat::ChatDelivery::Failed);
});
[this, echoLocalId, sessionGen](bool ok, const std::string& result) {
if (sessionGen != chat_session_generation_) return; // stale wallet — don't touch new session
inflight_op_ = LiteInflightOp{}; // release the channel for this session
LiteInflightOp op; op.echoLocalId = echoLocalId; op.sessionGen = sessionGen;
onChatBroadcastResult(op, ok, result);
},
/*background*/ true); // status is shown on the chat message, not the global send UI
return true; // submitted (async build/broadcast; the callback resolves the final status)
}
@@ -3021,11 +3050,9 @@ bool App::broadcastChatMemosLite(const chat::OutgoingChatMemos& memos)
recipient.memo = out.memo;
req.recipients.push_back(std::move(recipient));
}
if (!lite_wallet_->sendTransaction(req)) {
ui::Notifications::instance().error(TR("chat_toast_lite_busy"));
return false;
}
return true;
// No busy toast here: the coordinator (pumpChatNoteBuffer) owns this call, retries next frame when the
// single broadcast channel frees, and surfaces status on the message itself.
return lite_wallet_->sendTransaction(req);
}
void App::sendChatMessage(const std::string& conversationId, const std::string& text)
@@ -3073,10 +3100,9 @@ void App::sendChatMessage(const std::string& conversationId, const std::string&
echo.payload_position = 0;
echo.delivery = chat::ChatDelivery::Sending; // shows immediately; the broadcast callback resolves it
chat_service_.recordOutgoingPending(echo); // in-memory now; persisted with the final status
// A synchronous refusal (not connected / no funds) resolves to Failed right away so the Retry
// affordance appears; otherwise the async callback flips it to Sent/Failed.
if (!broadcastChatMemos(memos, echo.txid))
chat_service_.resolveOutgoing(echo.txid, chat::ChatDelivery::Failed);
// Both variants: hand to the note-buffer coordinator, which drains the queue against verified notes
// (building/refilling the buffer as needed) and resolves the echo Sent/Failed from the REAL result.
enqueueChatSend(LiteOpKind::ChatSend, memos, echo.txid);
}
void App::startChatConversation(const std::string& peerZaddr, const std::string& text)
@@ -3116,10 +3142,369 @@ void App::sendContactRequestForCid(const std::string& cid, const std::string& pe
echo.payload_position = 0;
echo.delivery = chat::ChatDelivery::Sending;
chat_service_.recordOutgoingPending(echo);
if (broadcastChatMemos(memos, echo.txid))
// Both variants: enqueue through the coordinator (resolves the echo from the real result).
enqueueChatSend(LiteOpKind::ContactRequest, memos, echo.txid);
ui::Notifications::instance().success(TR("chat_toast_request_queued"));
else
chat_service_.resolveOutgoing(echo.txid, chat::ChatDelivery::Failed);
}
// ── Chat note-buffer coordinator (lite only) ─────────────────────────────────────────────────────
// Design in app.h. Keeps ~kChatBufferTarget verified self-notes so a burst of messages each spends a
// separate note; refilled from change + background self-splits. Shares the controller's single broadcast
// channel with user sends (inflight_op_ tracks ownership). Pumped from App::update().
namespace {
constexpr int kChatBufferTarget = 10; // verified self-notes we aim to keep
constexpr int kChatRefillTrigger = 4; // (re)build only when the maturing pipeline drops below this
// A note is spendable when its witness count > ANCHOR_OFFSET, i.e. block depth >= ANCHOR_OFFSET+1 = 5
// (backend data.rs:543 needs anchor_offset+1 witnesses; ANCHOR_OFFSET=4 in lib.rs:25). Depth 4 is NOT yet
// spendable — counting it there wastes a proving round-trip on a guaranteed "insufficient verified funds".
constexpr int kChatConfsRequired = 5;
constexpr double kChatNoteSizeDrgx = 0.001; // each buffer note (change after a send stays >= fee)
constexpr double kChatSplitWatchdogSecs = 1200.0; // 20 min: clear a stuck split flag if it never mines (expiry/reorg)
constexpr int kMaxChatSendRetries = 20; // transient-funds requeues before giving up
inline std::uint64_t chatDrgxToZat(double drgx) {
return static_cast<std::uint64_t>(std::llround(drgx * 100000000.0));
}
} // namespace
// Confirmations a self-note needs before it's spendable: lite requires ANCHOR_OFFSET+1 witnesses (=5,
// data.rs:543); the full node's chat z_sendmany uses minconf=1 (app_network.cpp z_sendmany call).
int App::chatConfsRequired() const { return lite_wallet_ ? kChatConfsRequired : 1; }
// Status-bar summary of the chat note buffer (shown while the Chat tab is active). Empty unless chat has
// an identity. Surfaces the buffer state so the user can see it filling / draining, and it doubles as a
// diagnostic: "…" = no note data yet; "0/10 ready" = counted but no verified notes; "N/10 ready" = armed.
std::string App::chatBufferStatusText() {
if (!chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return "";
const int queued = static_cast<int>(chat_send_queue_.size());
const int verified = std::max(0, std::min(chat_verified_note_budget_, kChatBufferTarget));
const int target = static_cast<int>(kChatBufferTarget);
char buf[128];
if (queued > 0) {
snprintf(buf, sizeof(buf), TR(queued == 1 ? "chat_buffer_sending_one" : "chat_buffer_sending"), queued);
return buf;
}
if (chat_split_outstanding_) {
snprintf(buf, sizeof(buf), TR("chat_buffer_preparing"), verified, target);
return buf;
}
if (!chat_note_model_seen_)
return TR("chat_buffer_loading");
snprintf(buf, sizeof(buf), TR("chat_buffer_ready"), verified, target);
return buf;
}
bool App::isTransientVerifiedFundsError(const std::string& error) {
// Both variants' "only unconfirmed change is short" phrasing, treated as transient (retry after the
// buffer matures): lite = "Insufficient verified funds" (lightwallet.rs:2402); full node =
// "Insufficient shielded funds" (asyncrpcoperation_sendmany, parsed at parseInsufficientShielded).
return error.find("Insufficient verified funds") != std::string::npos
|| error.find("Insufficient shielded funds") != std::string::npos;
}
// Verified notes = ANY spendable shielded note (>= a fee) with >= kChatConfsRequired confirmations — the
// ones a chat send can spend RIGHT NOW. We count notes at ALL addresses, not just the reply address,
// because a chat send pays via chatPayFromZaddr (any funded z-addr) — gating on reply-address-only notes
// would queue a sendable message whenever funds sit elsewhere. NB: the model's per-note `spendable` flag
// means "unspent", NOT verified (lite_result_parsers.cpp recomputes it), so we derive it from depth here.
int App::verifiedSelfNoteCount(const wallet::LiteWalletAppRefreshModel& model) {
if (!model.hasSpendableOutputs) return 0;
// Use the cached tip (refreshChatNoteBudget updates it), not this model's — a notes-carrying model may
// not carry sync status that cycle, which would otherwise zero the whole count.
const std::int64_t tip = chat_last_chain_height_;
if (tip <= 0) return 0;
const std::uint64_t minVal = chatDrgxToZat(kChatMinFeeDrgx);
int n = 0;
for (const auto& o : model.spendableOutputs) {
if (o.kind != wallet::LiteSpendableOutputKind::UnspentNote) continue; // shielded notes only
if (o.spent || o.unconfirmedSpent || o.pending) continue;
if (o.valueZatoshis < minVal) continue; // skip dust below a fee
if (!o.createdInBlock.has_value()) continue; // unknown depth -> not verified
if (tip - *o.createdInBlock + 1 >= chatConfsRequired()) ++n;
}
return n;
}
// Notes that are verified OR still maturing (any unspent shielded note of usable size) — i.e. notes that
// will become spendable WITHOUT another split. Drives the refill trigger so we don't over-split while
// change is confirming or when the wallet already holds enough notes anywhere.
int App::pipelineSelfNoteCount(const wallet::LiteWalletAppRefreshModel& model) {
if (!model.hasSpendableOutputs) return 0;
const std::uint64_t minVal = chatDrgxToZat(kChatMinFeeDrgx);
int n = 0;
for (const auto& o : model.spendableOutputs) {
if (o.kind != wallet::LiteSpendableOutputKind::UnspentNote) continue;
if (o.spent || o.unconfirmedSpent) continue; // already consumed
if (o.valueZatoshis < minVal) continue;
++n; // verified or maturing (any addr)
}
return n;
}
// Recompute the cached note estimates from a fresh refresh model (called from update() when one arrives).
void App::refreshChatNoteBudget(const wallet::LiteWalletAppRefreshModel& model) {
if (!lite_wallet_ || !chat::hushChatFeatureEnabledAtBuild()) return;
// Cache the chain tip from whatever height source THIS model carries (sync status or chain info) — a
// model with notes may lack sync that cycle, so verifiedSelfNoteCount reads the cache below.
if (model.hasSyncStatus && model.sync.chainHeight > 0)
chat_last_chain_height_ = static_cast<std::int64_t>(model.sync.chainHeight);
else if (model.hasChainInfo && model.chain.latestBlockHeight.has_value() &&
*model.chain.latestBlockHeight > 0)
chat_last_chain_height_ = static_cast<std::int64_t>(*model.chain.latestBlockHeight);
if (model.hasSpendableOutputs) {
chat_note_model_seen_ = true;
chat_verified_note_budget_ = verifiedSelfNoteCount(model);
chat_pipeline_note_count_ = pipelineSelfNoteCount(model);
}
if (model.hasBalance)
chat_verified_shielded_zat_ = model.balance.verifiedShieldedZatoshis;
// Release the single-split guard once the outstanding split's outputs are mined+visible (pipeline
// recovered) — or once a watchdog window elapses, so a split that never mines can't wedge refill.
if (chat_split_outstanding_ &&
(chat_pipeline_note_count_ >= kChatRefillTrigger ||
ImGui::GetTime() - chat_split_submitted_at_ > kChatSplitWatchdogSecs)) {
chat_split_outstanding_ = false;
}
}
// Full-node twin of refreshChatNoteBudget: the shared balance poll discards per-note data, so run our own
// rate-limited z_listunspent worker scan (mirrors fastScanChatMemos) to count verified/maturing spendable
// notes. z_listunspent returns only the wallet's OWN notes, and a chat send pays from any of them
// (chatPayFromZaddr), so we count ALL addresses — gating on the reply address only would queue a sendable
// message when funds sit elsewhere. minconf=0 exposes maturing change so the pipeline count doesn't over-
// split. On an older daemon that rejects z_listunspent it leaves chat_note_model_seen_ false → the pump
// won't drain a phantom budget or split (graceful degradation to naive one-at-a-time sends).
void App::refreshChatNoteBudgetNode() {
if (lite_wallet_ || !chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return;
if (!state_.connected || !rpc_ || !worker_ || state_.isLocked()) return;
if (chat_note_scan_in_flight_) return; // one scan at a time
const double now = ImGui::GetTime();
if (now - chat_note_scan_last_ < 4.0) return; // rate limit; self-throttles via the in-flight flag too
chat_note_scan_last_ = now;
chat_note_scan_in_flight_ = true;
const int scanGen = chat_session_generation_;
const int confsNeeded = chatConfsRequired(); // == 1 for full node
const std::uint64_t minVal = chatDrgxToZat(kChatMinFeeDrgx);
worker_->post([this, scanGen, confsNeeded, minVal]() -> rpc::RPCWorker::MainCb {
int verified = 0, pipeline = 0;
std::uint64_t verifiedZat = 0;
bool ok = false;
try {
rpc::RPCClient::TraceScope trace("HushChat / note-buffer scan");
nlohmann::json notes = rpc_->call("z_listunspent", nlohmann::json::array({0})); // 0 = include maturing
if (notes.is_array()) {
ok = true;
for (const auto& nz : notes) {
if (!nz.is_object()) continue;
if (nz.value("locked", false)) continue; // tied up by an in-flight send
const std::uint64_t amtZat = chatDrgxToZat(nz.value("amount", 0.0));
if (amtZat < minVal) continue; // skip dust below a fee
// rawconfirmations is the TRUE depth; `confirmations` is dPoW-clamped to 1 and understates it.
const int confs = (nz.contains("rawconfirmations") && nz["rawconfirmations"].is_number_integer())
? nz["rawconfirmations"].get<int>()
: nz.value("confirmations", 0);
++pipeline; // unspent self-note (verified or maturing)
if (confs >= confsNeeded) { ++verified; verifiedZat += amtZat; }
}
}
} catch (const std::exception&) {}
return [this, scanGen, ok, verified, pipeline, verifiedZat]() {
if (scanGen != chat_session_generation_) return; // wallet switched — drop (reset cleared the flag)
chat_note_scan_in_flight_ = false;
if (!ok) return; // z_listunspent unavailable — leave caches as-is
chat_note_model_seen_ = true;
chat_verified_note_budget_ = verified;
chat_pipeline_note_count_ = pipeline;
chat_verified_shielded_zat_ = verifiedZat;
if (chat_split_outstanding_ &&
(chat_pipeline_note_count_ >= kChatRefillTrigger ||
ImGui::GetTime() - chat_split_submitted_at_ > kChatSplitWatchdogSecs)) {
chat_split_outstanding_ = false;
}
};
});
}
void App::enqueueChatSend(LiteOpKind kind, const chat::OutgoingChatMemos& memos, const std::string& echoLocalId) {
QueuedChatOp op;
op.kind = kind;
op.memos = memos; // kept so a transient-funds retry re-broadcasts, never recomposes
op.echoLocalId = echoLocalId;
op.sessionGen = chat_session_generation_;
chat_send_queue_.push_back(std::move(op));
wallet::liteLog("chat: queued outgoing " +
std::string(kind == LiteOpKind::ContactRequest ? "contact request" : "message") +
" (queue depth " + std::to_string(chat_send_queue_.size()) +
", verified notes ~" + std::to_string(chat_verified_note_budget_) + ")");
// pumpChatNoteBuffer() (next update tick) submits it when the channel is free + a verified note exists.
}
// Self-send that mints `noteCount` reply-address notes (funds stay in the wallet), building/refilling the
// buffer. Returns false if the single broadcast channel is busy (the pump retries next frame).
bool App::broadcastSelfSplitLite(int noteCount) {
if (!lite_wallet_ || noteCount < 1) return false;
const std::string myZ = chatReplyZaddr();
if (myZ.empty()) return false;
wallet::LiteSendRequest req;
const std::uint64_t noteZat = chatDrgxToZat(kChatNoteSizeDrgx);
for (int i = 0; i < noteCount; ++i) {
wallet::LiteSendRecipient r;
r.address = myZ;
r.amountZatoshis = noteZat;
req.recipients.push_back(std::move(r));
}
if (!lite_wallet_->sendTransaction(req)) return false; // channel busy — retry next frame
wallet::liteLog("Chat buffer: splitting funds into " + std::to_string(noteCount) +
" notes (self-send) so messages can be sent back-to-back");
return true;
}
// Full-node twin of broadcastSelfSplitLite: a z_sendmany self-send with `noteCount` outputs to our reply
// address (funds stay in the wallet), building/refilling the buffer. Caps outputs to stay well under the
// per-tx size limit after Sietch decoy padding. markFeeGapRetry=true is load-bearing — the fee-gap retry
// rebuilds a SINGLE-recipient tx and would collapse the multi-output split. The terminal callback releases
// the send channel; chat_split_outstanding_ stays set until a scan sees the outputs.
bool App::broadcastSelfSplitNode(int noteCount) {
if (lite_wallet_ || noteCount < 1) return false;
if (!state_.connected || !rpc_ || !worker_) return false;
const std::string myZ = chatReplyZaddr();
if (myZ.empty()) return false;
const double fee = kChatMinFeeDrgx;
// Pay from any funded z-address; the split's OUTPUTS still go to the reply address (building the buffer
// there). The note count now includes notes at every address and is sized against the total verified
// balance, so paying from wherever the funds actually sit is consistent — and it can't stall when funds
// aren't at the reply address (which paying strictly from myZ would).
const std::string from = chatPayFromZaddr(fee);
if (from.empty()) return false; // no fundable note — pump retries after a scan
const int n = std::min(noteCount, 40); // bounded so the split tx stays under max_tx_size after decoys
nlohmann::json recipients = nlohmann::json::array();
for (int i = 0; i < n; ++i) {
nlohmann::json r;
r["address"] = myZ;
r["amount"] = util::formatAmountFixed(kChatNoteSizeDrgx);
recipients.push_back(std::move(r));
}
const auto sessionGen = chat_session_generation_;
submitZSendMany(from, myZ, 0.0, fee, /*memo*/"", recipients,
"HushChat / note-buffer split", /*markFeeGapRetry*/ true,
[this, sessionGen](bool ok, const std::string& /*result*/) {
if (sessionGen != chat_session_generation_) return; // stale wallet
inflight_op_ = LiteInflightOp{}; // release the channel
// On success the outputs aren't mined yet — chat_split_outstanding_ clears on the
// next scan (pipeline recovered) or the watchdog. On FAILURE (e.g. a stale-scan
// insufficient-funds throw) clear it now so the pump can retry after the next scan
// instead of waiting out the 1200s watchdog. (No notes were created, so it's safe.)
if (!ok) chat_split_outstanding_ = false;
},
/*background*/ true); // autonomous buffer maintenance — no global send UI
wallet::liteLog("Chat buffer: splitting funds into " + std::to_string(n) +
" notes (self-send) so messages can be sent back-to-back");
return true;
}
bool App::shouldSplitChatBuffer() {
// Only for engaged chat users (has a conversation or a queued send) — never split for someone who
// never chats. Connected, unlocked, channel free, no split already outstanding, pipeline genuinely
// depleted, funded. NB: a wall-clock cooldown is deliberately NOT used — a split's outputs stay
// invisible until mined (~1 block, far longer than any UI cooldown), so we gate on a single-split-
// in-flight flag (cleared when the pipeline recovers or the watchdog fires) to prevent runaway splits.
if (chat_service_.store().conversationIds().empty() && chat_send_queue_.empty()) return false;
if (!state_.connected || state_.isLocked()) return false;
// Channel busy = our own op inflight, OR a user send holds the shared channel. Lite user sends set
// lite_send_callback_; full-node user sends don't touch inflight_op_, so they're detected via the
// shared single-flight counter + any outstanding opid (a chat/user/sweep z_sendmany not yet resolved).
if (inflight_op_.kind != LiteOpKind::None) return false;
if (lite_wallet_ ? (bool)lite_send_callback_
: (send_submissions_in_flight_ > 0 || !pending_opids_.empty())) return false;
if (chat_split_outstanding_) return false;
if (!chat_note_model_seen_) return false;
if (chat_pipeline_note_count_ >= kChatRefillTrigger) return false; // change refill covers it
if (chat_verified_shielded_zat_ < chatDrgxToZat(kChatNoteSizeDrgx + kChatMinFeeDrgx)) return false;
return true;
}
// Resolve the just-completed chat/contact broadcast onto its echo. The in-flight op is the queue FRONT
// (the pump submits it without popping) so we can requeue it in place on a transient-funds failure.
void App::onChatBroadcastResult(const LiteInflightOp& op, bool ok, const std::string& error) {
if (op.sessionGen != chat_session_generation_) return; // wallet switched since submit — echo is gone
const bool frontMatches = !chat_send_queue_.empty()
&& chat_send_queue_.front().echoLocalId == op.echoLocalId;
if (ok) {
chat_service_.resolveOutgoing(op.echoLocalId, chat::ChatDelivery::Sent);
if (frontMatches) chat_send_queue_.pop_front();
wallet::liteLog("chat: message broadcast (Sent); " + std::to_string(chat_send_queue_.size()) +
" still queued");
return;
}
if (frontMatches && isTransientVerifiedFundsError(error)
&& chat_send_queue_.front().retries < kMaxChatSendRetries) {
// The optimistic budget was wrong (change not confirmed yet). Stop draining until the next refresh
// restores the true verified-note count; keep the message queued ("Sending") and retry then.
chat_verified_note_budget_ = 0;
++chat_send_queue_.front().retries;
wallet::liteLog("chat: send deferred — funds still confirming (retry " +
std::to_string(chat_send_queue_.front().retries) + "); message stays 'Sending'");
return; // leave at front
}
chat_service_.resolveOutgoing(op.echoLocalId, chat::ChatDelivery::Failed); // hard failure -> Retry affordance
if (frontMatches) chat_send_queue_.pop_front();
wallet::liteLog("chat: send FAILED: " + error);
}
// Per-frame pump: drain the chat queue against verified notes (priority 1); otherwise build/refill the
// buffer during idle (priority 2). One op per frame — the single broadcast channel serializes the rest.
void App::pumpChatNoteBuffer() {
if (!chat::hushChatFeatureEnabledAtBuild()) return;
if (!chat_service_.hasIdentity() || !state_.connected) return;
// A locked wallet can't spend: submitting would return a non-transient lock error and flap the queued
// message to Failed. Hold everything (send + split) until unlock — messages stay "Sending".
if (state_.isLocked()) return;
// Channel busy — exactly one send must be outstanding. Lite serializes via inflight_op_ + the send
// callback; the full node has no single-broadcast channel, so we also honour the shared single-flight
// counter and any outstanding opid (a chat/user/sweep z_sendmany not yet terminally resolved). Without
// the latter two a chat send could overlap a user Send-tab send (which never sets inflight_op_).
if (inflight_op_.kind != LiteOpKind::None) return;
if (lite_wallet_ ? (bool)lite_send_callback_
: (send_submissions_in_flight_ > 0 || !pending_opids_.empty())) return;
// Priority 1: drain a queued chat send while we (optimistically) still have a verified note.
if (!chat_send_queue_.empty()) {
QueuedChatOp& front = chat_send_queue_.front();
if (front.sessionGen != chat_session_generation_) { chat_send_queue_.pop_front(); return; }
if (chat_verified_note_budget_ >= 1) {
// Submit onto the wallet's send channel; NOT popped until the async result resolves (so a
// transient-funds failure can requeue it in place). Full-node broadcastChatMemos registers a
// terminal opid callback that clears inflight_op_ + routes onChatBroadcastResult.
const bool submitted = lite_wallet_ ? broadcastChatMemosLite(front.memos)
: broadcastChatMemos(front.memos, front.echoLocalId);
if (submitted) {
inflight_op_ = LiteInflightOp{ front.kind, front.echoLocalId, front.sessionGen, ImGui::GetTime() };
--chat_verified_note_budget_; // this note is now (optimistically) spent
}
return; // one op per frame; the front waits until resolved
}
// No verified note available: leave it queued ("Sending"); fall through to maybe build the buffer.
}
// Priority 2: build/refill the buffer during idle.
if (shouldSplitChatBuffer()) {
const int need = kChatBufferTarget - chat_pipeline_note_count_;
const std::uint64_t noteZat = chatDrgxToZat(kChatNoteSizeDrgx);
const std::uint64_t feeZat = chatDrgxToZat(kChatMinFeeDrgx);
int affordable = 0;
if (chat_verified_shielded_zat_ > feeZat && noteZat > 0)
affordable = static_cast<int>((chat_verified_shielded_zat_ - feeZat) / noteZat);
const int n = std::min(need, affordable);
const bool submitted = n >= 1 && (lite_wallet_ ? broadcastSelfSplitLite(n)
: broadcastSelfSplitNode(n));
if (submitted) {
inflight_op_ = LiteInflightOp{ LiteOpKind::Split, {}, chat_session_generation_, ImGui::GetTime() };
// Block further splits until this one's outputs mine in (or the watchdog fires): the outputs are
// invisible to the note count for ~1 block, so without this we'd fire a fresh split every frame.
chat_split_outstanding_ = true;
chat_split_submitted_at_ = ImGui::GetTime();
}
}
}
// HushChat (lite variant): the full-node harvest works off z_viewtransaction, but the lite wallet's
@@ -3133,6 +3518,7 @@ void App::ingestLiteChatMemos(const wallet::LiteWalletAppRefreshModel& model)
std::unordered_map<std::string, chat::HushChatTransactionInput> byTxid;
std::unordered_map<std::string, std::int64_t> txTimestamps;
bool anyUnconfirmedChatTx = false; // did a chat-carrying tx arrive still-unconfirmed (0-conf mempool)?
for (const auto& tx : model.transactions) {
if (tx.kind != wallet::LiteWalletAppTransactionKind::Receive) continue; // only incoming carry chat
if (tx.memo.empty() || !tx.position) continue;
@@ -3140,6 +3526,7 @@ void App::ingestLiteChatMemos(const wallet::LiteWalletAppRefreshModel& model)
input.txid = tx.txid;
input.outputs.push_back({static_cast<std::size_t>(*tx.position), tx.memo});
txTimestamps[tx.txid] = tx.timestamp;
if (tx.unconfirmed) anyUnconfirmedChatTx = true;
}
if (byTxid.empty()) return;
@@ -3151,6 +3538,10 @@ void App::ingestLiteChatMemos(const wallet::LiteWalletAppRefreshModel& model)
if (!metadata.empty()) {
std::vector<std::string> newChatCids;
chat_service_.ingest(metadata, txTimestamps, std::time(nullptr), &newChatCids);
if (!newChatCids.empty())
wallet::liteLog("chat: harvested " + std::to_string(newChatCids.size()) + " new message(s) " +
(anyUnconfirmedChatTx ? "(0-conf / mempool — monitor working)"
: "(confirmed only — not seen at mempool speed)"));
// Mirror the full-node paths: a new incoming message un-hides a hidden conversation (you can't
// un-receive), and a non-muted new message toasts when we're off the Chat tab.
if (settings_) {
@@ -3182,6 +3573,8 @@ void App::fastScanChatMemos()
const int scanGen = chat_session_generation_; // guard: drop the result if the wallet switches/locks
worker_->post([this, addr, scanGen]() -> rpc::RPCWorker::MainCb {
std::vector<chat::HushChatTransactionMetadata> metadata;
int rawMemoCount = 0; // received notes carrying a memo at the reply addr (0-conf visibility signal)
std::string scanError;
try {
rpc::RPCClient::TraceScope trace("HushChat / 0-conf fast scan");
nlohmann::json received = rpc_->call("z_listreceivedbyaddress", {addr, 0}); // 0 = include mempool
@@ -3194,6 +3587,7 @@ void App::fastScanChatMemos()
const std::string txid = note.value("txid", std::string());
const std::string memo = note.value("memoStr", std::string());
if (txid.empty() || memo.empty()) continue;
++rawMemoCount;
std::size_t pos = fallbackPos;
for (const char* key : {"position", "outputIndex", "outindex"})
if (note.contains(key) && note[key].is_number_integer() && note[key].get<int>() >= 0) {
@@ -3209,13 +3603,25 @@ void App::fastScanChatMemos()
for (auto& m : extracted.metadata) metadata.push_back(std::move(m));
}
}
} catch (const std::exception&) {}
return [this, scanGen, metadata = std::move(metadata)]() mutable {
} catch (const std::exception& e) { scanError = e.what(); }
const int parsedCount = static_cast<int>(metadata.size());
return [this, scanGen, metadata = std::move(metadata), rawMemoCount, parsedCount, scanError]() mutable {
// The wallet was switched/locked between post and now — this metadata belongs to the previous
// session. resetChatSession already reset the in-flight flag, so just drop; clearing it here
// would clobber a new session's own in-flight scan (mirrors the broadcast/identity guards).
if (scanGen != chat_session_generation_) return;
chat_fast_scan_in_flight_ = false;
// Diagnostic (console App/chat channel): log when the memo-note count at the reply address
// CHANGES, so a stable mempool doesn't spam every 2.5s. Reveals whether inbound 0-conf messages
// are reaching us at all, and how many parse as chat vs are unrelated memos.
if (!scanError.empty()) {
wallet::liteLog("chat[0conf]: reply-addr scan failed: " + scanError);
} else if (rawMemoCount != chat_fast_scan_last_seen_) {
chat_fast_scan_last_seen_ = rawMemoCount;
if (rawMemoCount > 0)
wallet::liteLog("chat[0conf]: reply-addr scan sees " + std::to_string(rawMemoCount) +
" memo note(s), " + std::to_string(parsedCount) + " parse as chat");
}
if (metadata.empty()) return;
// Skip HIDDEN conversations — the mempool fast path deliberately doesn't surface them; they
// still come back through the normal confirmed harvest (which un-hides on a new message).
@@ -3229,6 +3635,9 @@ void App::fastScanChatMemos()
std::unordered_map<std::string, std::int64_t> noTimes; // mempool: no block time → ingest uses now
std::vector<std::string> newChatCids;
chat_service_.ingest(visible, noTimes, std::time(nullptr), &newChatCids);
if (!newChatCids.empty())
wallet::liteLog("chat[0conf]: harvested " + std::to_string(newChatCids.size()) +
" new message(s) from the mempool");
if (current_page_ != ui::NavPage::Chat &&
std::any_of(newChatCids.begin(), newChatCids.end(),
[this](const std::string& cid){ return !(settings_ && settings_->isChatMuted(cid)); }))
@@ -4087,6 +4496,9 @@ void App::sendTransaction(const std::string& from, const std::string& to,
return;
}
lite_send_callback_ = std::move(callback); // delivered from update()
// Claim the shared broadcast channel so the chat note-buffer coordinator yields to this user send
// and routes the one global broadcast result back to lite_send_callback_ (not a chat echo).
inflight_op_ = LiteInflightOp{ LiteOpKind::UserSend, {}, chat_session_generation_, ImGui::GetTime() };
return;
}
@@ -4149,12 +4561,13 @@ void App::sendTransaction(const std::string& from, const std::string& to,
void App::submitZSendMany(const std::string& from, const std::string& to, double amount, double fee,
const std::string& memo, const nlohmann::json& recipients,
const char* traceLabel, bool markFeeGapRetry,
std::function<void(bool, const std::string&)> callback)
std::function<void(bool, const std::string&)> callback,
bool background)
{
send_progress_active_ = true;
if (!background) send_progress_active_ = true; // autonomous chat/split: status shown on the message
++send_submissions_in_flight_;
worker_->post([this, from, to, amount, fee, memo, recipients, callback, traceLabel,
markFeeGapRetry]() -> rpc::RPCWorker::MainCb {
markFeeGapRetry, background]() -> rpc::RPCWorker::MainCb {
bool ok = false;
std::string result_str;
try {
@@ -4169,7 +4582,7 @@ void App::submitZSendMany(const std::string& from, const std::string& to, double
// below would never run, leaving a stuck "Sending…" spinner.
result_str = "Send failed (unknown error)";
}
return [this, callback, ok, result_str, from, to, amount, fee, memo, markFeeGapRetry]() {
return [this, callback, ok, result_str, from, to, amount, fee, memo, markFeeGapRetry, background]() {
if (send_submissions_in_flight_ > 0) --send_submissions_in_flight_;
if (ok) {
// A send changes address balances — refresh on next cycle
@@ -4192,7 +4605,7 @@ void App::submitZSendMany(const std::string& from, const std::string& to, double
callback(true, result_str); // no opid to track — report as-is
}
} else {
send_progress_active_ = false;
if (!background) send_progress_active_ = false;
if (callback) callback(false, result_str);
}
};

View File

@@ -16,7 +16,8 @@ std::string buildHeaderMemo(const std::string& replyZaddr,
const std::string& conversationId,
const char* type,
const std::string& streamHeaderHex,
const std::string& publicKeyHex)
const std::string& publicKeyHex,
std::int64_t sentAt)
{
nlohmann::json header;
header["h"] = 1; // header number (>= 1)
@@ -26,6 +27,7 @@ std::string buildHeaderMemo(const std::string& replyZaddr,
header["t"] = type; // "Memo" or "Cont"
header["e"] = streamHeaderHex; // 48-hex secretstream header (Memo) / "" (Cont)
header["p"] = publicKeyHex; // my 64-hex crypto_kx public key
if (sentAt > 0) header["ts"] = sentAt; // optional sender compose time (Unix s) — receiver shows this
return header.dump();
}
@@ -67,7 +69,8 @@ ChatComposeStatus buildOutgoingMessage(const ChatKeyPair& mine,
OutgoingChatMemos memos;
memos.recipientZaddr = peerZaddr;
memos.headerMemo = buildHeaderMemo(myReplyZaddr, conversationId, "Memo", streamHeaderHex, myPublicKeyHex);
memos.headerMemo = buildHeaderMemo(myReplyZaddr, conversationId, "Memo", streamHeaderHex, myPublicKeyHex,
static_cast<std::int64_t>(std::time(nullptr)));
memos.payloadMemo = ciphertextHex;
if (memos.headerMemo.size() > kHushChatMemoByteLimit ||
memos.payloadMemo.size() > kHushChatMemoByteLimit) {
@@ -95,7 +98,8 @@ ChatComposeStatus buildOutgoingContactRequest(const std::string& myPublicKeyHex,
OutgoingChatMemos memos;
memos.recipientZaddr = peerZaddr;
memos.headerMemo = buildHeaderMemo(myReplyZaddr, conversationId, "Cont", "", myPublicKeyHex);
memos.headerMemo = buildHeaderMemo(myReplyZaddr, conversationId, "Cont", "", myPublicKeyHex,
static_cast<std::int64_t>(std::time(nullptr)));
memos.payloadMemo = requestText;
if (memos.headerMemo.size() > kHushChatMemoByteLimit ||
memos.payloadMemo.size() > kHushChatMemoByteLimit) {

View File

@@ -124,6 +124,10 @@ HushChatHeaderParseResult parseHushChatHeaderMemo(const std::string& memo)
if (!readRequiredString(object, "t", type, error)) return fail(error);
if (!readRequiredString(object, "e", header.secretstream_header_hex, error)) return fail(error);
if (!readRequiredString(object, "p", header.public_key_hex, error)) return fail(error);
// Optional sender compose time (Unix seconds). Absent on older senders — leave sent_at = 0 so the
// receiver falls back to the tx/receive time. Read leniently; never fail the header on a bad value.
if (auto it = object.find("ts"); it != object.end() && it->is_number_integer())
header.sent_at = it->get<std::int64_t>();
if (header.header_number < 1) return fail("header number must be positive");
if (header.version != kHushChatSupportedVersion) return fail("unsupported HushChat version");
@@ -303,6 +307,7 @@ HushChatTransactionExtractionResult extractHushChatTransactionMetadata(
metadata.sender_public_key_hex = pair.header.public_key_hex;
metadata.secretstream_header_hex = pair.header.secretstream_header_hex;
metadata.payload_memo = pair.payload_memo;
metadata.sent_at = pair.header.sent_at; // carry the sender's compose time (0 if absent)
result.metadata.push_back(std::move(metadata));
}

View File

@@ -23,6 +23,9 @@ struct HushChatHeader {
HushChatHeaderType type = HushChatHeaderType::Message;
std::string secretstream_header_hex;
std::string public_key_hex;
// Optional sender-stamped compose time (header "ts", Unix seconds). 0 = absent (older sender) → the
// receiver falls back to the tx/receive time. Lets both sides show the SAME (send) time.
std::int64_t sent_at = 0;
};
struct HushChatHeaderParseResult {
@@ -80,6 +83,7 @@ struct HushChatTransactionMetadata {
std::string sender_public_key_hex; // header "p": peer crypto_kx public key (hex)
std::string secretstream_header_hex; // header "e": secretstream header (hex; empty for ContactRequest)
std::string payload_memo; // ciphertext hex (Message) or plaintext request text (ContactRequest)
std::int64_t sent_at = 0; // header "ts": sender compose time (Unix s); 0 = absent → use tx time
};
struct HushChatTransactionExtractionResult {

View File

@@ -45,8 +45,21 @@ int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata
message.conversation_id = meta.conversation_id;
message.peer_zaddr = meta.reply_zaddr;
message.peer_public_key_hex = meta.sender_public_key_hex;
// Reference time: the tx/receive time (block time if confirmed, else the receiver's wall clock for
// a mempool receive).
const auto timeIt = txTimestamps.find(meta.txid);
message.timestamp = timeIt != txTimestamps.end() ? timeIt->second : fallbackTimestamp;
const std::int64_t refTime = timeIt != txTimestamps.end() ? timeIt->second : fallbackTimestamp;
// Prefer the sender's stamped compose time (header "ts") — the true send time, shown identically on
// both ends. But REJECT a value implausibly in the FUTURE vs the reference: a wrong/ahead peer clock
// would otherwise pin their messages to the bottom of the thread forever. A compose time in the
// PAST is fine — the note buffer can broadcast a queued message long after it was composed, and a
// confirmed tx's block time is always >= the compose time.
constexpr std::int64_t kSenderTsFutureToleranceSec = 3600; // 1 hour of clock skew tolerated
if (meta.sent_at > 0 && (refTime <= 0 || meta.sent_at <= refTime + kSenderTsFutureToleranceSec)) {
message.timestamp = meta.sent_at;
} else {
message.timestamp = refTime;
}
message.payload_position = meta.payload_position;
if (meta.type == HushChatHeaderType::ContactRequest) {

View File

@@ -28,6 +28,16 @@ public:
// Distinct conversation ids, in first-seen order.
std::vector<std::string> conversationIds() const;
// The set of on-chain txids that carried a chat message (sent or received, messages + contact
// requests) — used by the History tab to badge / filter chat transactions. O(messages), no copies.
std::unordered_set<std::string> chatTxids() const {
std::unordered_set<std::string> out;
out.reserve(messages_.size());
for (const auto& m : messages_)
if (!m.txid.empty()) out.insert(m.txid);
return out;
}
std::size_t size() const { return messages_.size(); }
bool empty() const { return messages_.empty(); }
void clear();

View File

@@ -0,0 +1,39 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// Embedded source of the DragonX mark (res/img/logos/logo_dragonx.svg). Kept as a string so the logo
// is available in every build (dev + portable single-file) with no resource-pipeline or file dependency.
// It is rasterized + recolored per theme at runtime (see util/svg_texture.*). Two fills: the crimson
// body (.cls-2 #d82652) becomes the theme accent; the white detail (.cls-1 #fff) becomes the light tone.
#pragma once
// Global `embedded` namespace to match the generated embedded resources (embedded::ui_toml_data, etc.).
namespace embedded {
inline constexpr const char* kLogoDragonXSvg = R"SVG(<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<defs>
<style>
.cls-1 {
fill: #fff;
}
.cls-2 {
fill: #d82652;
}
</style>
</defs>
<path class="cls-2" d="M103.98,128s-6.29-24.7-18.73-34.43c-8.53-8.03-15.63-16.49-21.25-24.16-5.62,7.68-12.72,16.17-21.25,24.16-12.4,9.74-18.73,34.43-18.73,34.43-2.38-24.34,7.85-35.82,12.87-41.29,7.82-8.5,15.31-16.56,21.75-25.02-7.64-11.44-11.41-19.72-11.41-19.72-.89-3.27-3.84-6.64-3.84-6.64,6.08-8.1-1.6-16.98-1.6-16.98,5.79-5.62,6.71-10.02,8.32-18.34-1.96,22.35,4.02,39.09,13.93,54.12,9.84-15.03,15.81-31.77,13.86-54.12,1.6,8.35,2.52,12.72,8.32,18.34,0,0-7.68,8.88-1.6,16.98,0,0-2.95,3.38-3.84,6.64,0,0-3.77,8.28-11.37,19.72,6.43,8.45,13.97,16.56,21.75,25.02,4.97,5.47,15.21,16.95,12.83,41.29h0Z"/>
<g>
<path class="cls-1" d="M55.33,61.62c-3.55,4.55-7.39,8.99-11.44,13.47-5.29-4.48-11.23-5.33-11.23-5.33,22.92-7.82,2.81-15.17.28-16.31C9.28,42.78,9.1,14.78,9.1,14.78c11.51,34.15,36.42,32.3,36.42,32.3.35-.21.67-.46.92-.71,1.64,3.27,4.58,8.67,8.88,15.24h0Z"/>
<g>
<path class="cls-1" d="M68.62,40.41c-1.35,2.98-2.91,5.83-4.62,8.63-1.71-2.81-3.23-5.69-4.62-8.63,1.74-3.45,4.62-20.58,4.62-20.58,0,0,2.88,17.13,4.62,20.58Z"/>
<path class="cls-1" d="M76.01,97.93l-3.48,2.34s-.1-4.44-3.52-1.84c-.42.32-2.38,2.21-.03,4.27,0,0-4.05,4.08-4.97,8.21-.92-4.12-4.97-8.21-4.97-8.21,2.34-2.06.39-3.95-.03-4.27-3.41-2.59-3.52,1.84-3.52,1.84l-3.48-2.34c.28-3.55.1-6.68-.46-9.42,4.69-4.94,8.85-9.88,12.47-14.61,3.66,4.72,7.78,9.67,12.47,14.61-.57,2.74-.75,5.86-.46,9.42Z"/>
<path class="cls-1" d="M95.34,69.76s-5.94.85-11.23,5.33c-4.02-4.48-7.89-8.92-11.44-13.47,4.3-6.57,7.25-11.98,8.88-15.24.25.25.57.5.92.71,0,0,24.91,1.84,36.42-32.3,0,0-.18,28-23.84,38.66-2.52,1.14-22.64,8.5.28,16.31h0Z"/>
</g>
</g>
</svg>)SVG";
} // namespace embedded

View File

@@ -26,8 +26,10 @@
#include <algorithm>
#include <cctype>
#include <cfloat>
#include <cmath> // std::cos/std::sin — radial byte gauge in the composer
#include <cstdint>
#include <cstring>
#include <functional>
#include <ctime>
#include <string>
#include <vector>
@@ -41,9 +43,41 @@ namespace {
std::string s_selected_cid;
std::string s_scroll_to_cid; // when set, scroll the thread to the bottom next frame
// Auto-scroll: keep the thread pinned to the newest message only while the user is at the bottom.
// A wheel-up detaches it (mirrors console_tab.cpp's ScrollController); returning to the bottom re-arms it.
bool s_chat_auto_scroll = true;
float s_chat_scroll_cooldown = 0.0f; // seconds left before an at-bottom position may re-arm auto-scroll
// Message text selection: click-drag inside a bubble selects a byte range of its body; while a range is
// active a small copy button appears at the bubble's top on the opposite side. Offsets index m.body bytes.
std::string s_msgsel_cid; // conversation of the selected message ("" = no selection)
int s_msgsel_index = -1; // message index within that conversation
int s_msgsel_anchor = 0; // drag-start byte offset
int s_msgsel_head = 0; // drag-current byte offset
bool s_msgsel_dragging = false;
// Composer + new-conversation UI state.
char s_compose[512] = "";
std::string s_compose_cid; // the conversation s_compose is a draft for; draft is wiped when it changes
// On-chain chat body cap in bytes = (512 len("utf8:"))/2 secretstream ABYTES (see chat_outgoing.cpp).
// The composer hard-caps input to this; the emoji picker respects it too.
constexpr int kChatBodyMaxBytes = (512 - 5) / 2 - 17; // = 236
float s_composerAnimH = 0.0f; // animated composer-box height — grows with newlines, pushing the thread up
float s_composerTargetH = 0.0f; // target height measured in the composer block (wrapped content), consumed next frame
// Composer InputText callback: insert a newline at the cursor on Shift+Enter. ImGui 1.92's Enter handling
// (imgui_widgets.cpp:5078) resolves Enter via Shortcut() with EXACT modifiers, so Shift+Enter matches
// neither the plain-Enter (submit) nor the Ctrl+Enter shortcut — ImGui does nothing with it. We insert the
// newline ourselves here (running under CallbackAlways), respecting the on-chain byte cap.
int composeInputCallback(ImGuiInputTextCallbackData* data) {
ImGuiIO& io = ImGui::GetIO();
if (io.KeyShift
&& (ImGui::IsKeyPressed(ImGuiKey_Enter) || ImGui::IsKeyPressed(ImGuiKey_KeypadEnter))
&& data->BufTextLen < kChatBodyMaxBytes) {
data->InsertChars(data->CursorPos, "\n");
}
return 0;
}
bool s_show_new_convo = false;
char s_new_zaddr[128] = "";
char s_new_msg[256] = "";
@@ -165,6 +199,155 @@ ImU32 avatarColor(const std::string& seed) {
return kPalette[h % (sizeof(kPalette) / sizeof(kPalette[0]))];
}
// Lay out (and optionally draw) a chat message body with paragraph spacing: each '\n'-delimited paragraph
// word-wraps at wrapW, and every paragraph after the first gets `paraGap` of extra leading ABOVE it — so
// an explicit newline reads as a paragraph break (a bit more space than a soft wrap, which stays tight).
// Returns the total laid-out size (max wrapped width × total height). When dl != nullptr, draws at `origin`.
// Both the size pass (dl == nullptr) and the draw pass MUST use identical args so the bubble fits the text.
ImVec2 layoutChatBody(ImDrawList* dl, ImFont* font, float size, const std::string& body,
float wrapW, float paraGap, ImVec2 origin, ImU32 col) {
float maxW = 0.0f, y = 0.0f;
bool first = true;
size_t start = 0;
for (;;) {
const size_t nl = body.find('\n', start);
const bool last = (nl == std::string::npos);
const char* pbeg = body.c_str() + start;
const char* pend = body.c_str() + (last ? body.size() : nl);
if (!first) y += paraGap; // paragraph break gets extra room
const ImVec2 psz = font->CalcTextSizeA(size, FLT_MAX, wrapW, pbeg, pend);
const float ph = std::max(psz.y, size); // a blank line still takes one line
if (dl && pend > pbeg)
dl->AddText(font, size, ImVec2(origin.x, origin.y + y), col, pbeg, pend, wrapW);
y += ph;
maxW = std::max(maxW, psz.x);
first = false;
if (last) break;
start = nl + 1;
}
return ImVec2(maxW, y);
}
// One wrapped visual line of a laid-out chat body: byte range [b,e) into body + its y-offset from the
// body origin (each line is `size` tall). Mirrors layoutChatBody's paragraph/wrap walk so selection
// hit-testing and highlighting align with the drawn text.
struct ChatBodyLine { std::size_t b, e; float y; };
std::vector<ChatBodyLine> chatBodyLines(ImFont* font, float size, const std::string& body,
float wrapW, float paraGap) {
std::vector<ChatBodyLine> out;
const float scale = size / std::max(1.0f, font->LegacySize);
float y = 0.0f; bool first = true; std::size_t start = 0;
for (;;) {
const std::size_t nl = body.find('\n', start);
const bool last = (nl == std::string::npos);
const std::size_t pend = last ? body.size() : nl;
if (!first) y += paraGap;
std::size_t p = start;
if (p >= pend) { out.push_back({p, p, y}); y += size; } // blank paragraph = one empty line
else {
while (p < pend) {
const char* w = font->CalcWordWrapPositionA(scale, body.c_str() + p, body.c_str() + pend, wrapW);
std::size_t we = (w > body.c_str() + p) ? static_cast<std::size_t>(w - body.c_str()) : p + 1;
if (we > pend) we = pend;
out.push_back({p, we, y});
y += size;
p = we;
while (p < pend && body[p] == ' ') ++p; // AddText skips blanks at a wrap; match it
}
}
first = false;
if (last) break;
start = nl + 1;
}
return out;
}
// Advance one UTF-8 codepoint (byte offset) within [.. limit).
inline std::size_t chatNextCp(const std::string& s, std::size_t i, std::size_t limit) {
if (i >= limit) return limit;
++i;
while (i < limit && (static_cast<unsigned char>(s[i]) & 0xC0) == 0x80) ++i;
return i;
}
// Map a point to the nearest body byte offset (for drag selection). origin is the body's top-left.
std::size_t chatBodyHitTest(ImFont* font, float size, const std::string& body, float wrapW,
float paraGap, ImVec2 origin, ImVec2 pt) {
const auto lines = chatBodyLines(font, size, body, wrapW, paraGap);
if (lines.empty()) return 0;
const ChatBodyLine* L = &lines.front();
for (const auto& ln : lines) { if (pt.y >= origin.y + ln.y) L = &ln; else break; }
const float relX = pt.x - origin.x;
std::size_t best = L->b; float bestDx = 1e9f;
for (std::size_t i = L->b;; i = chatNextCp(body, i, L->e)) {
const float w = font->CalcTextSizeA(size, FLT_MAX, 0.0f, body.c_str() + L->b, body.c_str() + i).x;
const float dx = std::fabs(w - relX);
if (dx < bestDx) { bestDx = dx; best = i; }
if (i >= L->e) break;
}
return best;
}
// The custom-DragonX-emoji shortcode. Inserted by the emoji picker's DragonX tile; rendered inline as
// the mark in chat bubbles (see layoutChatBodyRich). Plain text on clients that don't support it.
static const char* kDrgxToken = ":drgx:";
// Lay out (and optionally draw) a message body that contains one or more ":drgx:" tokens, flowing text
// WORDS and the inline DragonX-emoji image left-to-right and wrapping at wrapW (each drgx renders as a
// `size`-square image). Only used for bodies that actually contain the token — plain bodies keep the
// tighter layoutChatBody path — so normal messages are unaffected. Returns the total laid-out size.
ImVec2 layoutChatBodyRich(ImDrawList* dl, ImFont* font, float size, const std::string& body, float wrapW,
float paraGap, ImVec2 origin, ImU32 col, ImTextureID drgxTex) {
const float spaceW = font->CalcTextSizeA(size, FLT_MAX, 0.0f, " ").x;
const float emojiW = size; // square, matching the line height
const std::size_t tokLen = std::strlen(kDrgxToken);
float maxW = 0.0f, y = 0.0f, x = 0.0f;
bool firstPara = true, firstOnLine = true;
// Place a token of width w on the current line, wrapping first if it doesn't fit; draw() gets the
// token's top-left screen position.
auto place = [&](float w, const std::function<void(float, float)>& draw) {
const float lead = firstOnLine ? 0.0f : spaceW;
if (!firstOnLine && x + lead + w > wrapW) { x = 0.0f; y += size; firstOnLine = true; }
const float sx = x + (firstOnLine ? 0.0f : spaceW);
if (dl) draw(origin.x + sx, origin.y + y);
x = sx + w;
if (x > maxW) maxW = x;
firstOnLine = false;
};
std::size_t pstart = 0;
for (;;) {
const std::size_t nl = body.find('\n', pstart);
const bool lastPara = (nl == std::string::npos);
const std::size_t pend = lastPara ? body.size() : nl;
if (!firstPara) y += paraGap;
x = 0.0f; firstOnLine = true;
std::size_t i = pstart;
while (i < pend) {
if (body[i] == ' ' || body[i] == '\t') { ++i; continue; }
if (body.compare(i, tokLen, kDrgxToken) == 0) {
place(emojiW, [&](float px, float py) {
if (drgxTex) dl->AddImage(drgxTex, ImVec2(px, py), ImVec2(px + emojiW, py + size));
else dl->AddText(font, size, ImVec2(px, py), col, kDrgxToken); // fallback: literal
});
i += tokLen;
continue;
}
std::size_t j = i;
while (j < pend && body[j] != ' ' && body[j] != '\t' && body.compare(j, tokLen, kDrgxToken) != 0) ++j;
const std::string word = body.substr(i, j - i);
const float ww = font->CalcTextSizeA(size, FLT_MAX, 0.0f, word.c_str()).x;
place(ww, [&, word](float px, float py) { dl->AddText(font, size, ImVec2(px, py), col, word.c_str()); });
i = j;
}
y += size;
firstPara = false;
if (lastPara) break;
pstart = nl + 1;
}
return ImVec2(maxW, y);
}
// Outgoing-bubble accent base color for a chat_bubble_accent preset (0 = theme primary).
ImU32 bubbleAccentColor(int accent) {
switch (accent) {
@@ -416,6 +599,13 @@ static const EmojiEntry kEmoji[] = {
{u8"🤢","nausea sick"},{u8"🤮","vomit sick"},{u8"🤧","sneeze sick"},{u8"😷","mask sick"},{u8"🤒","sick thermometer"},
{u8"🤕","hurt bandage injured"},{u8"😈","devil imp evil"},{u8"👿","devil angry"},{u8"💀","skull dead"},{u8"👻","ghost boo"},
{u8"👽","alien"},{u8"🤖","robot bot"},{u8"😺","cat smile"},{u8"🙀","cat scream"},
// Dragon-themed set (DRGX flavour — searchable by "dragon"/"drgx"). All verified present in the
// bundled mono NotoEmoji subset + the color Twemoji font.
{u8"🐉","dragon drgx dragonx wyrm mythical creature"},{u8"🐲","dragon face drgx dragonx"},
{u8"🐍","snake serpent reptile"},{u8"🦎","lizard reptile gecko"},{u8"🐊","crocodile croc reptile gator"},
{u8"🐾","paw tracks claws prints"},{u8"🥚","egg dragon egg"},{u8"🪺","nest eggs hatch"},
{u8"🏰","castle lair fortress keep"},{u8"","swords battle crossed fight"},
{u8"🛡","shield defense guard protect"},{u8"🗡","dagger blade sword knife"},
{u8"👍","thumbs up like yes good"},{u8"👎","thumbs down dislike no bad"},{u8"👌","ok perfect"},{u8"","peace victory"},
{u8"🤞","fingers crossed luck"},{u8"🤟","love you"},{u8"🤘","rock horns"},{u8"👏","clap applause"},
{u8"🙌","raise hands celebrate"},{u8"👐","open hands"},{u8"🙏","pray thanks please"},{u8"💪","muscle strong flex"},
@@ -434,7 +624,19 @@ static const EmojiEntry kEmoji[] = {
// Emoji picker overlay: fills the conversation-list pane (cancel + keyword search at the top, then a
// grid). Clicking an emoji appends its UTF-8 bytes to `buf` (the composer), respecting the buffer.
void renderEmojiPickerOverlay(char* buf, std::size_t bufSize) {
void renderEmojiPickerOverlay(char* buf, std::size_t bufSize, ImTextureID drgxTex) {
// Insert a token (emoji glyph or the ":drgx:" shortcode) at the end of the draft, prepending a space
// when the draft isn't empty and doesn't already end in whitespace. Respects the on-chain byte cap.
auto insertToken = [&](const char* tok) {
const std::size_t cur = std::strlen(buf), add = std::strlen(tok);
const bool needsSpace = cur > 0 && static_cast<unsigned char>(buf[cur - 1]) > ' ';
const std::size_t pad = needsSpace ? 1 : 0;
if (cur + pad + add <= static_cast<std::size_t>(kChatBodyMaxBytes) && cur + pad + add < bufSize) {
if (needsSpace) buf[cur] = ' ';
std::memcpy(buf + cur + pad, tok, add);
buf[cur + pad + add] = '\0';
}
};
if (ImGui::SmallButton(TR("chat_cancel"))) { s_show_emoji_picker = false; s_emoji_search[0] = '\0'; return; }
ImGui::SameLine();
ImGui::SetNextItemWidth(-FLT_MIN);
@@ -460,13 +662,25 @@ void renderEmojiPickerOverlay(char* buf, std::size_t bufSize) {
es.bgRounding = 8.0f * dp;
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(spacing, spacing));
int shown = 0;
// DragonX custom emoji tile (first cell) — inserts the ":drgx:" shortcode, which renders inline as the
// mark in message bubbles. Shown unless the search filter excludes "dragon"/"drgx".
if (q.empty() || containsCI("dragon drgx dragonx wyrm", q)) {
ImGui::PushID("drgxtile");
const ImVec2 tp = ImGui::GetCursorScreenPos();
ImGui::InvisibleButton("##drgx", ImVec2(cell, cell));
ImDrawList* gdl = ImGui::GetWindowDrawList();
if (ImGui::IsItemHovered()) gdl->AddRectFilled(tp, ImVec2(tp.x + cell, tp.y + cell), es.hoverBg, es.bgRounding);
if (drgxTex) gdl->AddImage(drgxTex, tp, ImVec2(tp.x + cell, tp.y + cell));
else gdl->AddText(emojiFont, emojiFont->LegacySize, tp, material::OnSurface(), u8"🐉");
if (ImGui::IsItemClicked()) insertToken(kDrgxToken);
ImGui::PopID();
if (++shown % perRow != 0) ImGui::SameLine();
}
for (const auto& e : kEmoji) {
if (!q.empty() && !containsCI(e.keywords, q)) continue;
ImGui::PushID(shown);
if (material::IconButton("##em", e.glyph, emojiFont, ImVec2(cell, cell), es)) {
const std::size_t cur = std::strlen(buf), add = std::strlen(e.glyph);
if (cur + add < bufSize) { std::memcpy(buf + cur, e.glyph, add); buf[cur + add] = '\0'; }
}
if (material::IconButton("##em", e.glyph, emojiFont, ImVec2(cell, cell), es))
insertToken(e.glyph);
ImGui::PopID();
if (++shown % perRow != 0) ImGui::SameLine();
}
@@ -534,6 +748,7 @@ void RenderChatTab(App* app)
if (s_selected_cid != s_compose_cid) {
sodium_memzero(s_compose, sizeof(s_compose));
s_compose_cid = s_selected_cid;
s_composerAnimH = 0.0f; // re-arm the first-frame snap so the box doesn't animate-collapse on switch
}
// Abandon an in-progress header rename tied to a different conversation (switch/hide), so a
// half-typed buffer can't silently reappear or land on the wrong contact (Tier C review).
@@ -575,7 +790,7 @@ void RenderChatTab(App* app)
material::ApplySmoothScroll(); // wheel-driven lerp scroll, matching the rest of the app
if (s_show_emoji_picker) {
// Emoji picker overlay takes over the conversation-list pane while open (search + cancel + grid).
renderEmojiPickerOverlay(s_compose, sizeof(s_compose));
renderEmojiPickerOverlay(s_compose, sizeof(s_compose), app->getDrgxEmojiTexture());
} else {
// "New chat" (accented) + "Show hidden (N)" toggle, side by side. Show-hidden only appears when
// something is hidden; otherwise New chat spans the row.
@@ -705,11 +920,28 @@ void RenderChatTab(App* app)
for (const auto& c : convs) if (c.cid == s_selected_cid) { sel = &c; break; }
const float tdp = Layout::dpiScale();
const float composerBoxH = ImGui::GetTextLineHeight() * 2.6f; // multi-line composer (Q6)
// Reserve a stable composer strip below the box whenever a conversation is open (thin counter row +
// the input row, which sits flush with the strip/window bottom). Same height for the waiting hint so
// the box doesn't jump when a reply arrives.
const float composerAreaH = sel ? (composerBoxH + metaSz + 12.0f * tdp) : 0.0f;
// Composer auto-grows with its content: the input word-wraps, so the box height tracks the wrapped
// line count (measured in the composer block below, where the wrap width is known) and animates so the
// thread above is smoothly pushed up/down. The vertical padding also centers a single line and, with
// the left FramePadding, keeps text off the edges. We consume last frame's measured target here (a
// 1-frame lag that's invisible under the smoothing); it falls back to one collapsed line.
const float lineH = ImGui::GetTextLineHeight();
const float composerVPad = 8.0f * tdp; // top+bottom inner padding
const float composerCtrlH = lineH + 2.0f * composerVPad; // one-line (collapsed) height; side controls match
const int kMaxComposerLines = 6; // beyond this the input scrolls internally
const float composerAnimTarget = (s_composerTargetH > 0.0f) ? s_composerTargetH : composerCtrlH;
if (s_composerAnimH <= 0.0f) s_composerAnimH = composerAnimTarget; // first frame: snap, don't animate in
{
const float dt = ImGui::GetIO().DeltaTime;
const float k = 1.0f - std::exp(-dt * 16.0f); // ~16/s exponential convergence
s_composerAnimH += (composerAnimTarget - s_composerAnimH) * k;
if (std::fabs(composerAnimTarget - s_composerAnimH) < 0.5f) s_composerAnimH = composerAnimTarget;
}
const float composerBoxH = s_composerAnimH;
// Reserve the (animated) composer strip below the box whenever a conversation is open — just the input
// row (the byte budget is a radial gauge inside the input, no separate counter row). Same height for
// the waiting hint so the box doesn't jump when a reply arrives.
const float composerAreaH = sel ? (composerBoxH + 10.0f * tdp) : 0.0f;
// Frosted-glass thread pane (same reasoning as the list): glass behind + transparent child bg so
// the message area blurs the backdrop instead of showing the sharp texture. Replaces the old
@@ -978,9 +1210,32 @@ void RenderChatTab(App* app)
ImGui::BeginChild("##ChatMessages", ImVec2(0, ImGui::GetContentRegionAvail().y),
ImGuiChildFlags_AlwaysUseWindowPadding, ImGuiWindowFlags_NoScrollWithMouse);
material::ApplySmoothScroll(); // smooth wheel scroll; syncs with the auto-scroll-to-bottom below
// Detach auto-scroll when the user wheels up inside the thread; re-armed once back at the bottom
// (below). The cooldown lets the smooth-scroll animate away before we re-check position. A direct
// mouse-in-rect test (not IsWindowHovered, which is false while the composer holds focus) matches
// ApplySmoothScroll, so scrolling up to read history while the composer is focused still detaches.
{
const ImVec2 wpos = ImGui::GetWindowPos(), wsz = ImGui::GetWindowSize();
const ImVec2 mp = ImGui::GetIO().MousePos;
const bool inThread = mp.x >= wpos.x && mp.x < wpos.x + wsz.x &&
mp.y >= wpos.y && mp.y < wpos.y + wsz.y;
if (inThread && ImGui::GetIO().MouseWheel > 0.0f) {
s_chat_auto_scroll = false;
s_chat_scroll_cooldown = 0.3f;
}
}
if (s_chat_scroll_cooldown > 0.0f) s_chat_scroll_cooldown -= ImGui::GetIO().DeltaTime;
// Was the thread at the bottom coming into this frame? (measured before this frame's content is
// laid out, so newly-arrived messages don't yank a scrolled-up reader.)
const float preScrollMaxY = ImGui::GetScrollMaxY();
const bool wasAtBottom = preScrollMaxY <= 0.0f || ImGui::GetScrollY() >= preScrollMaxY - 4.0f;
bool atBottom = true;
const ImVec2 msgWinMin = ImGui::GetWindowPos();
const ImVec2 msgWinSize = ImGui::GetWindowSize();
// Jump-to-latest pill geometry — computed here so the clear-on-empty-click below can exclude
// the pill (a pill click shouldn't drop an active selection) and the pill draw below reuses it.
const ImVec2 jumpPillMin(msgWinMin.x + msgWinSize.x - 88.0f * dp, msgWinMin.y + msgWinSize.y - 34.0f * dp);
const ImVec2 jumpPillMax(jumpPillMin.x + 78.0f * dp, jumpPillMin.y + 26.0f * dp);
{
ImDrawList* dl = ImGui::GetWindowDrawList();
// Bubble style / accent / density / text-size from chat customization settings.
@@ -1012,6 +1267,8 @@ void RenderChatTab(App* app)
ImFont* icf = material::Type().iconSmall();
const float icfSz = scaledSize(icf);
std::int64_t prevTs = -1; int prevDir = -1; std::int64_t prevDay = -1;
bool anyMsgClicked = false; // did a left-click this frame land on a bubble / copy button?
const ImTextureID drgxTex = app->getDrgxEmojiTexture(); // ":drgx:" inline emoji
for (std::size_t mi = 0; mi < messages.size(); ++mi) {
const auto& m = messages[mi];
ImGui::PushID(static_cast<int>(mi));
@@ -1066,7 +1323,14 @@ void RenderChatTab(App* app)
}
// ── Direction-aligned bubble (rounding/fill/accent from settings; grouped corners).
const ImVec2 tsz = nameFont->CalcTextSizeA(bodySz, innerW, innerW, m.body.c_str());
// Body is laid out paragraph-by-paragraph so explicit newlines get extra leading.
// A body containing ":drgx:" uses the richer inline-image flow (drgx tile → mark);
// plain bodies keep the tighter text-only path so they're unaffected.
const float paraGap = bodySz * 0.35f;
const bool hasDrgx = m.body.find(kDrgxToken) != std::string::npos;
const ImVec2 tsz = hasDrgx
? layoutChatBodyRich(nullptr, nameFont, bodySz, m.body, innerW, paraGap, ImVec2(0, 0), 0, drgxTex)
: layoutChatBody(nullptr, nameFont, bodySz, m.body, innerW, paraGap, ImVec2(0, 0), 0);
const float bw = std::min(maxBubbleW, tsz.x + 2.0f * bpad);
const float bh = tsz.y + 2.0f * bpad;
const ImVec2 cur = ImGui::GetCursorScreenPos();
@@ -1087,8 +1351,48 @@ void RenderChatTab(App* app)
}
}
dl->AddRectFilled(bmin, bmax, bubCol, bround, rf);
dl->AddText(nameFont, bodySz, ImVec2(bx + bpad, cur.y + bpad), material::OnSurface(),
m.body.c_str(), nullptr, innerW);
// ── Text selection: click-drag inside the bubble selects a byte range of the body.
// Skipped for ":drgx:" messages — their inline-image layout (layoutChatBodyRich) doesn't
// match the plain-text hit-test/highlight geometry, so a selection would misalign.
// (Right-click "copy" still copies the whole message.)
const ImVec2 bodyOrigin(bx + bpad, cur.y + bpad);
const bool selfSel = (!hasDrgx && s_msgsel_cid == s_selected_cid && s_msgsel_index == static_cast<int>(mi));
if (!hasDrgx && ImGui::IsMouseHoveringRect(bmin, bmax) && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
s_msgsel_cid = s_selected_cid; s_msgsel_index = static_cast<int>(mi);
s_msgsel_anchor = s_msgsel_head = static_cast<int>(
chatBodyHitTest(nameFont, bodySz, m.body, innerW, paraGap, bodyOrigin, ImGui::GetMousePos()));
s_msgsel_dragging = true;
anyMsgClicked = true;
}
if (selfSel && s_msgsel_dragging) {
if (ImGui::IsMouseDown(ImGuiMouseButton_Left))
s_msgsel_head = static_cast<int>(
chatBodyHitTest(nameFont, bodySz, m.body, innerW, paraGap, bodyOrigin, ImGui::GetMousePos()));
else
s_msgsel_dragging = false;
}
// Highlight the selected range (behind the text) — one rect per wrapped line segment.
if (selfSel && s_msgsel_anchor != s_msgsel_head) {
const int a = std::min(s_msgsel_anchor, s_msgsel_head);
const int b2 = std::max(s_msgsel_anchor, s_msgsel_head);
for (const auto& ln : chatBodyLines(nameFont, bodySz, m.body, innerW, paraGap)) {
const int ls = std::max(a, static_cast<int>(ln.b));
const int le = std::min(b2, static_cast<int>(ln.e));
if (ls >= le) continue;
const float x0 = nameFont->CalcTextSizeA(bodySz, FLT_MAX, 0.0f, m.body.c_str() + ln.b, m.body.c_str() + ls).x;
const float x1 = nameFont->CalcTextSizeA(bodySz, FLT_MAX, 0.0f, m.body.c_str() + ln.b, m.body.c_str() + le).x;
dl->AddRectFilled(ImVec2(bodyOrigin.x + x0, bodyOrigin.y + ln.y),
ImVec2(bodyOrigin.x + x1, bodyOrigin.y + ln.y + bodySz),
material::WithAlpha(material::Primary(), 80));
}
}
if (hasDrgx)
layoutChatBodyRich(dl, nameFont, bodySz, m.body, innerW, paraGap,
ImVec2(bx + bpad, cur.y + bpad), material::OnSurface(), drgxTex);
else
layoutChatBody(dl, nameFont, bodySz, m.body, innerW, paraGap,
ImVec2(bx + bpad, cur.y + bpad), material::OnSurface());
// Peer avatar beside the LAST incoming bubble of a run (Tier 3).
if (!outgoing && lastInGroup) {
@@ -1115,6 +1419,31 @@ void RenderChatTab(App* app)
ImGui::EndPopup();
}
// Copy button for an active selection — at the bubble TOP, on the OPPOSITE side (an
// incoming bubble sits left → button to its right; an outgoing bubble sits right →
// button to its left). Copies the selected substring of the body.
if (selfSel && s_msgsel_anchor != s_msgsel_head) {
const int a = std::min(s_msgsel_anchor, s_msgsel_head);
const int b2 = std::max(s_msgsel_anchor, s_msgsel_head);
const float cbs = 22.0f * dp;
const float cbx = outgoing ? (bmin.x - cbs - 4.0f * dp) : (bmax.x + 4.0f * dp);
const ImVec2 cbmin(cbx, bmin.y), cbmax(cbx + cbs, bmin.y + cbs);
const bool cbhov = ImGui::IsMouseHoveringRect(cbmin, cbmax);
dl->AddRectFilled(cbmin, cbmax, cbhov ? material::Primary()
: material::WithAlpha(material::Primary(), 210), 5.0f * dp);
const float gs = icfSz * 0.78f;
const ImVec2 gsz = icf->CalcTextSizeA(gs, FLT_MAX, 0.0f, ICON_MD_CONTENT_COPY);
dl->AddText(icf, gs, ImVec2(cbmin.x + (cbs - gsz.x) * 0.5f, cbmin.y + (cbs - gsz.y) * 0.5f),
IM_COL32(255, 255, 255, 235), ICON_MD_CONTENT_COPY);
if (cbhov) {
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
ImGui::SetClipboardText(m.body.substr(a, b2 - a).c_str());
anyMsgClicked = true;
}
}
}
// Delivery status for your messages: clock while sending, check once broadcast (Tier 2).
if (outgoing && !failed && (sending || lastInGroup)) {
const char* glyph = sending ? ICON_MD_SCHEDULE : ICON_MD_DONE;
@@ -1147,27 +1476,53 @@ void RenderChatTab(App* app)
ImGui::PopID();
prevTs = m.timestamp; prevDir = static_cast<int>(outgoing); prevDay = day;
}
if (s_scroll_to_cid == s_selected_cid) {
// A left-click inside the thread that hit no bubble or copy button clears the selection —
// except a click on the jump-to-latest pill, which is navigation, not a deselect.
if (ImGui::IsMouseClicked(ImGuiMouseButton_Left) && !anyMsgClicked && ImGui::IsWindowHovered()
&& !ImGui::IsMouseHoveringRect(jumpPillMin, jumpPillMax)) {
s_msgsel_cid.clear(); s_msgsel_index = -1; s_msgsel_dragging = false;
}
// Pin to the newest message when explicitly requested (send / conversation switch) or when
// auto-scroll is armed and the thread was already at the bottom (so new messages follow).
if (s_scroll_to_cid == s_selected_cid || (s_chat_auto_scroll && wasAtBottom)) {
ImGui::SetScrollHereY(1.0f);
s_scroll_to_cid.clear();
}
atBottom = ImGui::GetScrollY() >= ImGui::GetScrollMaxY() - 4.0f;
atBottom = ImGui::GetScrollMaxY() <= 0.0f || ImGui::GetScrollY() >= ImGui::GetScrollMaxY() - 4.0f;
// Re-arm auto-scroll once the user returns to the bottom (after the wheel cooldown).
if (atBottom && s_chat_scroll_cooldown <= 0.0f) s_chat_auto_scroll = true;
// Jump-to-latest pill when scrolled up (Q9). Drawn LAST and INSIDE the thread child (on the
// child's own draw list) so it sits on top of the messages — a parent-window draw after
// EndChild renders UNDER the child and gets covered by the text. PushClipRect keeps the
// child's content padding / scrollbar from clipping it. Hand-drawn + IsMouseHoveringRect so
// the click lands despite the child owning hover here (a real widget would still miss it).
if (!atBottom) {
const ImVec2 pmin = jumpPillMin, pmax = jumpPillMax;
const float pw = pmax.x - pmin.x, ph = pmax.y - pmin.y;
const bool hov = ImGui::IsMouseHoveringRect(pmin, pmax);
dl->PushClipRect(pmin, pmax, false);
dl->AddRectFilled(pmin, pmax,
hov ? material::Primary() : material::WithAlpha(material::Primary(), 220),
ph * 0.5f);
ImFont* lf = ImGui::GetFont(); const float lsz = ImGui::GetFontSize();
const char* lbl = TR("chat_jump_latest");
const ImVec2 tszp = lf->CalcTextSizeA(lsz, FLT_MAX, 0.0f, lbl);
dl->AddText(lf, lsz, ImVec2(pmin.x + (pw - tszp.x) * 0.5f, pmin.y + (ph - tszp.y) * 0.5f),
IM_COL32(255, 255, 255, 235), lbl);
dl->PopClipRect();
if (hov) {
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
s_scroll_to_cid = sel->cid;
s_chat_auto_scroll = true; // clicking Latest re-arms follow-the-newest
s_chat_scroll_cooldown = 0.0f;
}
}
}
}
ImGui::EndChild();
ImGui::PopStyleVar(2); // ##ChatMessages WindowPadding + zeroed vertical ItemSpacing
// Jump-to-latest pill when scrolled up (Q9). SetCursorScreenPos moves the parent cursor to
// the child's bottom edge; save + restore it so the composer footer below stays anchored
// (otherwise it renders ~8px too high only while the pill is shown).
if (!atBottom) {
const ImVec2 savedCursor = ImGui::GetCursorScreenPos();
const ImVec2 pill(msgWinMin.x + msgWinSize.x - 84.0f * dp, msgWinMin.y + msgWinSize.y - 34.0f * dp);
ImGui::SetCursorScreenPos(pill);
ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Primary(), 220)));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(material::Primary()));
if (ImGui::Button(TR("chat_jump_latest"), ImVec2(74.0f * dp, 26.0f * dp))) s_scroll_to_cid = sel->cid;
ImGui::PopStyleColor(2);
ImGui::SetCursorScreenPos(savedCursor);
}
// Composer is rendered OUTSIDE this box, directly below it (see after EndChild).
} else {
if (convs.empty()) centeredEmptyState(ICON_MD_FORUM, TR("chat_empty_title"), TR("chat_empty_start"));
@@ -1188,6 +1543,7 @@ void RenderChatTab(App* app)
const float cx = boxMin.x;
const float cw = boxMax.x - boxMin.x;
if (sel->peerPubKey.empty()) {
s_composerTargetH = composerCtrlH; // no editable composer here — keep the strip collapsed
ImGui::SetCursorScreenPos(ImVec2(cx, boxMax.y + 8.0f * tdp));
ImGui::PushFont(metaFont);
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
@@ -1197,31 +1553,36 @@ void RenderChatTab(App* app)
ImGui::PopStyleColor();
ImGui::PopFont();
} else {
// Multi-line composer (Q6). Enter behavior is setting-dependent (see below). A live byte
// counter tracks the on-chain body cap = (512 len("utf8:"))/2 secretstream ABYTES.
static constexpr int kBodyMaxBytes = (512 - 5) / 2 - 17; // = 236 (see chat_outgoing.cpp)
// Multi-line composer (Q6). Enter behavior is setting-dependent (see below). Input is
// hard-capped to the on-chain body byte budget; usage is shown as a radial gauge in the box.
const int kBodyMaxBytes = kChatBodyMaxBytes;
const float sendW = 80.0f * tdp; // scaled so the translated label never clips (B3)
const float emojiBtn = 28.0f * tdp;
const float inGap = 6.0f * tdp;
const int used = static_cast<int>(std::strlen(s_compose));
const bool overCap = used > kBodyMaxBytes;
const bool overCap = used > kBodyMaxBytes; // defensive — the hard byte cap should prevent this
// Thin counter row (right-aligned) just under the box; over-cap tints + prefixes a label.
std::string counterLine = std::to_string(used) + " / " + std::to_string(kBodyMaxBytes);
if (overCap) counterLine = std::string(TR("chat_len_over")) + " " + counterLine;
const float cwid = metaFont->CalcTextSizeA(metaSz, FLT_MAX, 0.0f, counterLine.c_str()).x;
ImGui::GetWindowDrawList()->AddText(metaFont, metaSz,
ImVec2(cx + cw - cwid, boxMax.y + 3.0f * tdp),
overCap ? material::Error() : material::OnSurfaceMedium(), counterLine.c_str());
// Fraction of the on-chain cap used, for the radial gauge (drawn inside the input, below).
const float frac = kBodyMaxBytes > 0 ? static_cast<float>(used) / kBodyMaxBytes : 0.0f;
// Input row, flush with the strip/window bottom: [emoji] [input] [Send].
// Input row: [emoji] [input] [Send]. The input's TOP is pinned 10px below the message box and
// it grows DOWNWARD as it gets taller (the thread above shrinks), so the row bottom stays put.
const float rowY = boxMax.y + composerAreaH - composerBoxH;
const float inputX = cx + emojiBtn + inGap;
const float inputW = std::max(60.0f * tdp, cw - emojiBtn - 2.0f * inGap - sendW);
const float ctrlY = rowY + composerBoxH - composerCtrlH; // bottom-aligned row for side controls
// Radial byte gauge sits in the input box's bottom-right corner; a fixed small size (tied to
// the line height, NOT the animated box height) with a reserved right slot so text never
// reaches it. Geometry first so the input width floor can guarantee room for both.
const float ringR = std::max(7.0f, lineH * 0.42f);
const float ringPad = 9.0f * tdp;
const float ringSlot = 2.0f * ringR + ringPad * 1.6f;
const float inputW = std::max(ringSlot + 48.0f * tdp, cw - emojiBtn - 2.0f * inGap - sendW);
const float sendX = inputX + inputW + inGap;
const float textW = std::max(40.0f * tdp, inputW - ringSlot); // input area, left of the ring
const ImVec2 ringC(inputX + inputW - ringPad - ringR, rowY + composerBoxH - ringPad - ringR);
// Emoji toggle on the left, vertically centered in the input row.
ImGui::SetCursorScreenPos(ImVec2(cx, rowY + (composerBoxH - emojiBtn) * 0.5f));
// Emoji toggle on the left, bottom-aligned with the input row (centered in the control height).
ImGui::SetCursorScreenPos(ImVec2(cx, ctrlY + (composerCtrlH - emojiBtn) * 0.5f));
{
material::IconButtonStyle es;
es.hoverBg = material::WithAlpha(material::OnSurface(), 30);
@@ -1244,33 +1605,112 @@ void RenderChatTab(App* app)
ImGui::PushStyleColor(ImGuiCol_FrameBg, IM_COL32(0, 0, 0, 0));
ImGui::PushStyleColor(ImGuiCol_FrameBgHovered, IM_COL32(0, 0, 0, 0));
ImGui::PushStyleColor(ImGuiCol_FrameBgActive, IM_COL32(0, 0, 0, 0));
// FramePadding gives the text a left inset (so it doesn't hug the box edge) and, since the
// collapsed box is exactly one line + 2×VPad tall, vertically centers a single line.
ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(12.0f * tdp, composerVPad));
ImGui::SetCursorScreenPos(ImVec2(inputX, rowY));
// Enter-to-send (setting): on → Enter sends + Ctrl+Enter newline; off → Enter is a newline and
// the Send button is the only way to send. NOTE: without EnterReturnsTrue, InputTextMultiline
// returns true on EVERY edit (value-changed), so in off-mode the return value must NOT drive
// submit — otherwise typing one character would fire a send.
// Enter-to-send (setting): on → Enter sends, Shift+Enter makes a newline (Ctrl+Enter too); off
// → Enter is a newline and the Send button is the only way to send. EnterReturnsTrue needs
// CtrlEnterForNewLine alongside it or multiline plain-Enter inserts a '\n' instead of
// validating (imgui_widgets.cpp:5118). Shift+Enter is handled in composeInputCallback since
// ImGui's exact-modifier Enter shortcut ignores it. CallbackAlways runs that handler.
const bool enterSends = !app->settings() || app->settings()->getChatEnterSends();
const ImGuiInputTextFlags composeFlags = enterSends
? (ImGuiInputTextFlags_EnterReturnsTrue | ImGuiInputTextFlags_CtrlEnterForNewLine)
: ImGuiInputTextFlags_None;
const bool inputReturned = ImGui::InputTextMultiline("##compose", s_compose, sizeof(s_compose),
ImVec2(inputW, composerBoxH), composeFlags);
// WordWrap: long lines wrap to the next visual line (the box grows) instead of scrolling the
// text horizontally off the left edge.
ImGuiInputTextFlags composeFlags = ImGuiInputTextFlags_CallbackAlways | ImGuiInputTextFlags_WordWrap;
if (enterSends)
composeFlags |= ImGuiInputTextFlags_EnterReturnsTrue | ImGuiInputTextFlags_CtrlEnterForNewLine;
// Hard-cap the byte length at the on-chain budget: passing kBodyMaxBytes+1 as the buffer size
// stops ImGui from accepting more input (UTF-8-safe), so the user can't type past the cap.
const bool inputReturned = ImGui::InputTextMultiline("##compose", s_compose, kBodyMaxBytes + 1,
ImVec2(textW, composerBoxH), composeFlags, composeInputCallback);
ImGui::PopStyleVar(); // FramePadding
ImGui::PopStyleColor(3); // composer FrameBg (transparent — glass shows through)
bool submit = enterSends && inputReturned; // off-mode sends only via the Send button
// Send.
ImGui::SetCursorScreenPos(ImVec2(sendX, rowY));
// Measure the wrapped content height to drive the auto-grow (consumed next frame at the top).
// Wrap width mirrors ImGui's internal InputText wrap = frame inner width (textW 2×FramePadding.x),
// so CalcTextSize's wrap matches what the widget renders.
{
const float wrapW = std::max(1.0f, textW - 2.0f * 12.0f * tdp);
float contentH = (s_compose[0] == '\0') ? lineH
: ImGui::CalcTextSize(s_compose, nullptr, false, wrapW).y;
const size_t clen = std::strlen(s_compose);
if (clen > 0 && s_compose[clen - 1] == '\n') contentH += lineH; // trailing empty line
contentH = std::min(contentH, kMaxComposerLines * lineH);
s_composerTargetH = std::max(lineH, contentH) + 2.0f * composerVPad;
}
// ── Radial byte gauge (replaces the old numeric counter): a ring in the input's right edge
// that fills clockwise and shifts green→amber→red as the message nears the on-chain cap.
// Full + red at/over the cap (Send is disabled there); exact count shows on hover.
{
ImDrawList* gdl = ImGui::GetWindowDrawList();
auto lerpCol = [](ImU32 a, ImU32 b, float t) {
if (t < 0.0f) t = 0.0f; else if (t > 1.0f) t = 1.0f;
const ImVec4 fa = ImGui::ColorConvertU32ToFloat4(a);
const ImVec4 fb = ImGui::ColorConvertU32ToFloat4(b);
return ImGui::GetColorU32(ImVec4(fa.x + (fb.x - fa.x) * t, fa.y + (fb.y - fa.y) * t,
fa.z + (fb.z - fa.z) * t, fa.w + (fb.w - fa.w) * t));
};
const ImU32 green = material::Success(), amber = material::Warning(), red = material::Error();
ImU32 fillCol;
if (frac < 0.75f) fillCol = green; // calm until genuinely near the cap
else if (frac < 1.0f) { const float t = (frac - 0.75f) / 0.25f;
fillCol = (t < 0.5f) ? lerpCol(green, amber, t / 0.5f)
: lerpCol(amber, red, (t - 0.5f) / 0.5f); }
else fillCol = red; // at/over cap
const float thick = std::max(2.0f, 2.4f * tdp);
const float dispFrac = frac < 0.0f ? 0.0f : (frac > 1.0f ? 1.0f : frac);
// Faint full-ring track (alpha kept high enough to read on light skins too).
gdl->PathArcTo(ringC, ringR, 0.0f, IM_PI * 2.0f, 48);
gdl->PathStroke(material::WithAlpha(material::OnSurface(), 42), 0, thick);
// Progress arc from 12 o'clock, clockwise, with a rounded head.
if (dispFrac > 0.0001f) {
const float a0 = -IM_PI * 0.5f;
const float a1 = a0 + dispFrac * IM_PI * 2.0f;
gdl->PathArcTo(ringC, ringR, a0, a1, 48);
gdl->PathStroke(fillCol, 0, thick);
gdl->AddCircleFilled(ImVec2(ringC.x + std::cos(a1) * ringR, ringC.y + std::sin(a1) * ringR),
thick * 0.6f, fillCol, 10);
}
// Exact count on hover (progressive disclosure; over-cap prefixes a label).
const float hitR = ringR + thick;
if (ImGui::IsMouseHoveringRect(ImVec2(ringC.x - hitR, ringC.y - hitR),
ImVec2(ringC.x + hitR, ringC.y + hitR))) {
std::string tip = std::to_string(used) + " / " + std::to_string(kBodyMaxBytes);
if (overCap) tip = std::string(TR("chat_len_over")) + " " + tip;
ImGui::BeginTooltip();
ImGui::TextUnformatted(tip.c_str());
ImGui::EndTooltip();
}
}
// Off-mode sends only via the Send button. In enter-sends mode EnterReturnsTrue makes the
// return value mean "validated" (plain Enter only) — Shift+Enter/edits don't set it.
bool submit = enterSends && inputReturned;
// Send — fixed height, bottom-aligned with the input row (doesn't stretch when the box grows).
ImGui::SetCursorScreenPos(ImVec2(sendX, ctrlY));
ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Primary(), 205)));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(material::Primary()));
ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Primary(), 235)));
ImGui::BeginDisabled(overCap || s_compose[0] == '\0');
if (material::TactileButton(TR("chat_send"), ImVec2(sendW, composerBoxH))) submit = true;
if (material::TactileButton(TR("chat_send"), ImVec2(sendW, composerCtrlH))) submit = true;
ImGui::EndDisabled();
// When Send is disabled *because the message is over the cap*, explain why on hover — the
// gauge went red but the numeric reason is no longer always-visible.
if (overCap && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
ImGui::SetTooltip("%s %d / %d", TR("chat_len_over"), used, kBodyMaxBytes);
ImGui::PopStyleColor(3);
if (submit && s_compose[0] != '\0' && !overCap) {
app->sendChatMessage(sel->cid, s_compose);
sodium_memzero(s_compose, sizeof(s_compose));
s_scroll_to_cid = sel->cid;
s_composerAnimH = 0.0f; // snap back to collapsed instead of animating while unfocused
// Sending closes the emoji picker (it takes over the conversation-list pane) so the list
// reappears, and clears its search filter.
s_show_emoji_picker = false;
s_emoji_search[0] = '\0';
}
}
}

View File

@@ -29,6 +29,10 @@ namespace ui {
using namespace material;
// Classifies a diagnostics line into a console channel (defined below in an anonymous namespace); both
// executors drain the same LiteDiagnostics ring, so declare it up front for the full-node drain.
namespace { ConsoleChannel liteLogChannel(const std::string& line); }
// ============================================================================
// FullNodeConsoleExecutor
// ============================================================================
@@ -173,6 +177,25 @@ void FullNodeConsoleExecutor::pollLogLines(const ConsoleAddLineFn& add)
} else {
last_xmrig_output_size_ = 0; // reset so we get fresh output when it restarts
}
// Chat diagnostics ring: chat code logs via wallet::liteLog on both variants (send lifecycle, the
// 0-conf harvest, the note buffer). Surface those lines here as App messages so the full-node console
// shows chat activity too. Same generation-cursor drain the lite console uses.
{
auto& diag = wallet::LiteDiagnostics::instance();
const std::uint64_t gen = diag.generation();
if (gen != diag_gen_) {
const auto snap = diag.snapshot();
std::size_t startIdx = 0;
if (diag_gen_ != static_cast<std::uint64_t>(-1)) {
const std::uint64_t added = gen - diag_gen_;
startIdx = (added >= snap.size()) ? 0 : snap.size() - static_cast<std::size_t>(added);
}
for (std::size_t i = startIdx; i < snap.size(); ++i)
add(snap[i], liteLogChannel(snap[i]));
diag_gen_ = gen;
}
}
}
void FullNodeConsoleExecutor::printHelp(const ConsoleAddLineFn& add)

View File

@@ -113,6 +113,9 @@ private:
bool last_rpc_connected_ = false;
std::deque<std::pair<std::string, bool>> results_; // {text, isError}
std::mutex results_mutex_;
// Chat diagnostics ring cursor: chat code logs via wallet::liteLog on BOTH variants, so the full-node
// console surfaces those lines (as App messages) too — mirrors LiteConsoleExecutor's diag drain.
std::uint64_t diag_gen_ = static_cast<std::uint64_t>(-1);
};
// ── Lite: diagnostics ring + backend console command ─────────────────────────

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);

View File

@@ -266,6 +266,13 @@ void I18n::loadBuiltinEnglish()
strings_["chat_rename"] = "Rename contact";
strings_["chat_rename_hint"] = "Contact name";
strings_["chat_renamed"] = "Contact renamed";
// Chat note-buffer status (status bar, while the Chat tab is active). %d are counts;
// keep the format specifiers intact in translations.
strings_["chat_buffer_sending_one"] = "Chat: sending %d message\xE2\x80\xA6";
strings_["chat_buffer_sending"] = "Chat: sending %d messages\xE2\x80\xA6";
strings_["chat_buffer_preparing"] = "Chat buffer: preparing %d/%d\xE2\x80\xA6";
strings_["chat_buffer_loading"] = "Chat buffer: \xE2\x80\xA6";
strings_["chat_buffer_ready"] = "Chat buffer: %d/%d ready";
// Chat customization (gear modal + Settings → Chat & Contacts)
strings_["chat_settings_title"] = "Chat settings";
strings_["chat_settings_tip"] = "Chat customization";
@@ -1004,6 +1011,7 @@ void I18n::loadBuiltinEnglish()
strings_["sent_filter"] = "Sent";
strings_["received_filter"] = "Received";
strings_["mined_filter"] = "Mined";
strings_["chat_filter"] = "Chat";
strings_["sort_date_newest"] = "Newest first";
strings_["sort_date_oldest"] = "Oldest first";
strings_["sort_amount_high"] = "Largest amount";
@@ -1019,6 +1027,7 @@ void I18n::loadBuiltinEnglish()
strings_["address_upper"] = "ADDRESS";
strings_["memo_upper"] = "MEMO";
strings_["shielded_type"] = "Shielded";
strings_["tx_chat_badge"] = "Message";
strings_["recv_type"] = "Recv";
strings_["sent_type"] = "Sent";
strings_["immature_type"] = "Immature";

75
src/util/svg_texture.cpp Normal file
View File

@@ -0,0 +1,75 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#include "svg_texture.h"
#include "texture_loader.h" // CreateRawTexture
#include "logger.h"
// nanosvg (memononen, zlib/public-domain, vendored in libs/nanosvg). Implementations compiled ONLY here.
#define NANOSVG_IMPLEMENTATION
#include "nanosvg/nanosvg.h"
#define NANOSVGRAST_IMPLEMENTATION
#include "nanosvg/nanosvgrast.h"
#include <algorithm>
#include <string>
#include <vector>
namespace dragonx {
namespace util {
// Perceptual brightness of a nanosvg color (byte order R,G,B,A — matches ImU32/IM_COL32).
static inline float svgLuma(unsigned int c) {
const float r = (c & 0xFFu) / 255.0f;
const float g = ((c >> 8) & 0xFFu) / 255.0f;
const float b = ((c >> 16) & 0xFFu) / 255.0f;
return 0.299f * r + 0.587f * g + 0.114f * b;
}
bool LoadTextureFromSvg(const char* svgText, int pxSize, ImU32 bodyColor, ImU32 detailColor,
ImTextureID* outTex, int* outW, int* outH) {
if (!svgText || pxSize <= 0 || !outTex) return false;
// nsvgParse mutates its input buffer — parse a copy.
std::string buf(svgText);
NSVGimage* img = nsvgParse(&buf[0], "px", 96.0f);
if (!img || img->width <= 0.0f || img->height <= 0.0f) {
if (img) nsvgDelete(img);
DEBUG_LOGF("LoadTextureFromSvg: parse failed / empty image\n");
return false;
}
// Recolor by brightness: light fills -> detail, darker fills -> body. Force opaque so a theme color
// with partial alpha can't make the mark translucent. ImU32 and nanosvg color share byte order.
const unsigned int body = (static_cast<unsigned int>(bodyColor) & 0x00FFFFFFu) | 0xFF000000u;
const unsigned int detail = (static_cast<unsigned int>(detailColor) & 0x00FFFFFFu) | 0xFF000000u;
for (NSVGshape* s = img->shapes; s; s = s->next) {
if (s->fill.type == NSVG_PAINT_COLOR)
s->fill.color = (svgLuma(s->fill.color) > 0.6f) ? detail : body;
if (s->stroke.type == NSVG_PAINT_COLOR)
s->stroke.color = (svgLuma(s->stroke.color) > 0.6f) ? detail : body;
}
const float scale = static_cast<float>(pxSize) / img->width;
const int w = pxSize;
const int h = std::max(1, static_cast<int>(img->height * scale + 0.5f));
std::vector<unsigned char> rgba(static_cast<size_t>(w) * h * 4, 0);
NSVGrasterizer* rast = nsvgCreateRasterizer();
if (!rast) { nsvgDelete(img); return false; }
nsvgRasterize(rast, img, 0.0f, 0.0f, scale, rgba.data(), w, h, w * 4);
nsvgDeleteRasterizer(rast);
nsvgDelete(img);
if (!CreateRawTexture(rgba.data(), w, h, /*repeat=*/false, outTex)) {
DEBUG_LOGF("LoadTextureFromSvg: CreateRawTexture failed (%dx%d)\n", w, h);
return false;
}
if (outW) *outW = w;
if (outH) *outH = h;
return true;
}
} // namespace util
} // namespace dragonx

24
src/util/svg_texture.h Normal file
View File

@@ -0,0 +1,24 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// Rasterize a (two-tone) SVG to a themed RGBA GPU texture via nanosvg. Used for the DragonX logo /
// custom emoji: the darker fill becomes `bodyColor` (the theme accent), the light fill `detailColor`.
#pragma once
#include "imgui.h"
namespace dragonx {
namespace util {
// Rasterize `svgText` (nul-terminated SVG markup) to a texture of width `pxSize` (height follows the
// SVG aspect). Fills are recolored by brightness — light fills -> detailColor, darker fills -> bodyColor
// (both forced opaque). Returns false on parse/rasterize/upload failure; on success *outTex is a texture
// the caller owns (release via util::DestroyTexture). Safe to call off the initial frame (needs a live
// GL/DX device, like the other texture loaders).
bool LoadTextureFromSvg(const char* svgText, int pxSize, ImU32 bodyColor, ImU32 detailColor,
ImTextureID* outTex, int* outW, int* outH);
} // namespace util
} // namespace dragonx

View File

@@ -6198,12 +6198,17 @@ void testHushChatOutgoing()
ChatService bobSvc;
bobSvc.setIdentity(bob);
EXPECT_EQ(bobSvc.ingest(extracted.metadata, {}, 5), 1);
// Realistic receive-time fallback (the header "ts" is a real std::time stamp; an artificially-tiny
// fallback would trip the future-plausibility clamp in ingest and reject the real ts).
EXPECT_EQ(bobSvc.ingest(extracted.metadata, {}, static_cast<std::int64_t>(std::time(nullptr))), 1);
std::vector<ChatMessage> conv = bobSvc.store().conversation(cid);
EXPECT_EQ((int)conv.size(), 1);
EXPECT_EQ(conv[0].body, std::string("hello bob"));
EXPECT_EQ(conv[0].peer_public_key_hex, ra.public_key_hex); // Bob learns Alice's key
EXPECT_EQ(conv[0].peer_zaddr, aliceZ);
// The header now carries the sender's compose time (header "ts"); ingest prefers it over the fallback
// (5) so both ends show the same send time. A real std::time() stamp is well past year-2001.
EXPECT_TRUE(conv[0].timestamp > 1000000000);
// Plaintext contact request Alice -> Bob.
OutgoingChatMemos creq;
@@ -6240,7 +6245,9 @@ void testHushChatOutgoing()
echo.peer_zaddr = aliceZ;
echo.peer_public_key_hex = ra.public_key_hex;
echo.body = "reply!";
echo.timestamp = 7;
// A reply is sent AFTER the received message; the incoming "hello bob" now carries a real compose-time
// "ts" (~now), so stamp the echo just after it (not the old artificial 7, which would sort before it).
echo.timestamp = static_cast<std::int64_t>(std::time(nullptr)) + 10;
echo.txid = "out:local1";
echo.payload_position = 0;
EXPECT_TRUE(bobSvc.recordOutgoing(echo));
@@ -6248,6 +6255,27 @@ void testHushChatOutgoing()
EXPECT_EQ((int)conv2.size(), 2);
EXPECT_TRUE(conv2.back().direction == ChatDirection::Outgoing);
EXPECT_EQ(conv2.back().body, std::string("reply!"));
// Future-clock clamp: a header "ts" implausibly ahead of the receive time is rejected (falls back to
// the receive time), so a wrong/ahead-clocked peer can't pin their messages to the bottom of a thread.
{
const std::int64_t nowSec = static_cast<std::int64_t>(std::time(nullptr));
const std::int64_t futureTs = nowSec + 10 * 24 * 3600; // 10 days ahead — well past the 1h tolerance
nlohmann::json h;
h["h"] = 1; h["v"] = 0; h["z"] = aliceZ; h["cid"] = "conv-clamp";
h["t"] = "Cont"; h["e"] = ""; h["p"] = ra.public_key_hex; h["ts"] = futureTs;
HushChatTransactionInput ctx;
ctx.txid = "txclamp";
ctx.outputs.push_back({0, h.dump()}); // header at the lower position
ctx.outputs.push_back({1, "please add"}); // contact-request plaintext payload (must not start with '{')
auto exc = extractHushChatTransactionMetadata(ctx, true);
EXPECT_EQ((int)exc.metadata.size(), 1);
EXPECT_EQ(exc.metadata[0].sent_at, futureTs); // the future ts parses through
bobSvc.ingest(exc.metadata, {}, nowSec); // realistic receive time
auto cc = bobSvc.store().conversation("conv-clamp");
EXPECT_EQ((int)cc.size(), 1);
EXPECT_EQ(cc[0].timestamp, nowSec); // clamped to receive time, NOT the future ts
}
}
// Phase 5: the transport encoding (chatSendOutputs) — header first, "utf8:" for full-node / raw for