Compare commits
13 Commits
31ebfc782e
...
203967411a
| Author | SHA1 | Date | |
|---|---|---|---|
| 203967411a | |||
| 3d0305a9ca | |||
| e7f38c2a45 | |||
| 3a2be661ea | |||
| 17525003c5 | |||
| 267d839d5b | |||
| 987d9b93c7 | |||
| 4c43f2e082 | |||
| 57fa6470b7 | |||
| c68889d276 | |||
| b0a4333cdf | |||
| 4471f54842 | |||
| e08121af2b |
@@ -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
3278
libs/nanosvg/nanosvg.h
Normal file
File diff suppressed because it is too large
Load Diff
1472
libs/nanosvg/nanosvgrast.h
Normal file
1472
libs/nanosvg/nanosvgrast.h
Normal file
File diff suppressed because it is too large
Load Diff
Binary file not shown.
23
res/img/logos/logo_dragonx.svg
Normal file
23
res/img/logos/logo_dragonx.svg
Normal 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 |
@@ -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:",
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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 :",
|
||||
|
||||
@@ -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": "送信元アドレス:",
|
||||
|
||||
@@ -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": "보낸 주소:",
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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": "Адрес отправителя:",
|
||||
|
||||
@@ -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": "发送地址:",
|
||||
|
||||
249
src/app.cpp
249
src/app.cpp
@@ -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);
|
||||
if (lite_send_callback_) {
|
||||
lite_send_callback_(broadcast.ok, broadcast.ok ? broadcast.txid : broadcast.error);
|
||||
lite_send_callback_ = nullptr;
|
||||
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;
|
||||
|
||||
83
src/app.h
83
src/app.h
@@ -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;
|
||||
|
||||
@@ -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))
|
||||
ui::Notifications::instance().success(TR("chat_toast_request_queued"));
|
||||
else
|
||||
chat_service_.resolveOutgoing(echo.txid, chat::ChatDelivery::Failed);
|
||||
// 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"));
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
39
src/embedded/logo_dragonx_svg.h
Normal file
39
src/embedded/logo_dragonx_svg.h
Normal 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
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ─────────────────────────
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
75
src/util/svg_texture.cpp
Normal 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
24
src/util/svg_texture.h
Normal 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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user