From 63ed31d2ec9871c4da08816a84a396d8f63dc5ed Mon Sep 17 00:00:00 2001 From: DanS Date: Fri, 17 Jul 2026 09:14:44 -0500 Subject: [PATCH] feat(chat): polish thread header, emoji picker, and pane gutter Tier A + B chat-tab visual pass: - Widen the list<->thread gutter (1px -> 10px) for breathing room. - Redesign the thread header: avatar + name on the left with a right-aligned frameless icon toolbar (add-contact/export/mute/hide, each with a tooltip); the name is clipped to the toolbar's left edge so a long contact label can't overrun the icons. - Replace the "Copy Full Address" button with a click-to-copy shortened address (copies the full z-addr), preceded by a key-verify lock whose tooltip shows a comparable identity-key fingerprint. - Show a "Waiting for reply" chip in the header when the peer's identity key isn't known yet. - Emoji picker: frameless grid with tight cells (glyphs fill the cell) and leftover width spread into the column gaps so it stays edge-to-edge. - Larger 30px composer emoji toggle that lights up while the picker is open; footer height + byte-counter centering adjusted to match. New i18n keys (chat_copy_address_tip / chat_verify_key / chat_awaiting_key) added additively to all 8 languages; CJK subset font rebuilt for the new zh glyph. Co-Authored-By: Claude Opus 4.8 (1M context) --- res/fonts/NotoSansCJK-Subset.ttf | Bin 639240 -> 639684 bytes res/lang/de.json | 3 + res/lang/es.json | 3 + res/lang/fr.json | 3 + res/lang/ja.json | 3 + res/lang/ko.json | 3 + res/lang/pt.json | 3 + res/lang/ru.json | 3 + res/lang/zh.json | 3 + src/ui/windows/chat_tab.cpp | 270 +++++++++++++++++++++++-------- src/util/i18n.cpp | 3 + 11 files changed, 226 insertions(+), 71 deletions(-) diff --git a/res/fonts/NotoSansCJK-Subset.ttf b/res/fonts/NotoSansCJK-Subset.ttf index 3a521b7cb52b819b064438d7c413f8c5c2a19b2a..2e32b5e198012eab4466866b6fa64386771b8a70 100644 GIT binary patch delta 13234 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 S?P77Pv{Ef|#}3iS`F{Xy=TXT3 delta 12832 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 XnWrp6^($p;S|*NF%owp#1eg3D$xqaY diff --git a/res/lang/de.json b/res/lang/de.json index 9189287..3979d02 100644 --- a/res/lang/de.json +++ b/res/lang/de.json @@ -136,9 +136,11 @@ "characters": "Zeichen", "chat": "Chat", "chat_add_contact": "Kontakt hinzufügen", + "chat_awaiting_key": "Warten auf Antwort", "chat_cancel": "Abbrechen", "chat_contact_added": "Kontakt hinzugefügt – benenne ihn in Kontakte um", "chat_contact_request": "kontaktanfrage", + "chat_copy_address_tip": "Zum Kopieren der Adresse klicken", "chat_emoji_search": "Emoji suchen", "chat_empty_hint": "Noch keine Unterhaltungen. Nachrichten, die du erhältst, erscheinen hier.", "chat_empty_start": "Starte eine mit \"Neue Unterhaltung\".", @@ -181,6 +183,7 @@ "chat_toast_waiting_reply": "Warte auf die Antwort des Kontakts, bevor du ihm schreiben kannst.", "chat_unhide": "Einblenden", "chat_unmute": "Stummschaltung aufheben", + "chat_verify_key": "Identitätsschlüssel – zum Verifizieren vergleichen", "chat_waiting_reply": "Warte auf die Antwort dieses Kontakts – sobald er antwortet, kannst du ihm schreiben.", "chat_you": "Du", "choose_icon": "Symbol wählen", diff --git a/res/lang/es.json b/res/lang/es.json index a2020a4..7a551f2 100644 --- a/res/lang/es.json +++ b/res/lang/es.json @@ -136,9 +136,11 @@ "characters": "caracteres", "chat": "Chat", "chat_add_contact": "Añadir contacto", + "chat_awaiting_key": "Esperando respuesta", "chat_cancel": "Cancelar", "chat_contact_added": "Contacto añadido: renómbralo en Contactos", "chat_contact_request": "solicitud de contacto", + "chat_copy_address_tip": "Clic para copiar la dirección", "chat_emoji_search": "Buscar emoji", "chat_empty_hint": "Aún no hay conversaciones. Los mensajes que recibas aparecerán aquí.", "chat_empty_start": "Inicia una con \"Nueva conversación\".", @@ -181,6 +183,7 @@ "chat_toast_waiting_reply": "Espera a que este contacto responda antes de poder escribirle.", "chat_unhide": "Mostrar", "chat_unmute": "Reactivar", + "chat_verify_key": "Clave de identidad: compárala para verificar", "chat_waiting_reply": "Esperando a que este contacto responda: podrás escribirle una vez lo haga.", "chat_you": "Tú", "choose_icon": "Elegir Icono", diff --git a/res/lang/fr.json b/res/lang/fr.json index fba109d..685a5ba 100644 --- a/res/lang/fr.json +++ b/res/lang/fr.json @@ -136,9 +136,11 @@ "characters": "caractères", "chat": "Discussion", "chat_add_contact": "Ajouter un contact", + "chat_awaiting_key": "En attente de réponse", "chat_cancel": "Annuler", "chat_contact_added": "Contact ajouté — renommez-le dans Contacts", "chat_contact_request": "demande de contact", + "chat_copy_address_tip": "Cliquer pour copier l'adresse", "chat_emoji_search": "Rechercher un emoji", "chat_empty_hint": "Aucune conversation pour l'instant. Les messages que vous recevez apparaîtront ici.", "chat_empty_start": "Commencez-en une avec « Nouvelle conversation ».", @@ -181,6 +183,7 @@ "chat_toast_waiting_reply": "En attente de la réponse de ce contact avant de pouvoir lui écrire.", "chat_unhide": "Afficher", "chat_unmute": "Réactiver", + "chat_verify_key": "Clé d'identité — comparez pour vérifier", "chat_waiting_reply": "En attente de la réponse de ce contact — vous pourrez lui écrire dès qu'il aura répondu.", "chat_you": "Vous", "choose_icon": "Choisir une icône", diff --git a/res/lang/ja.json b/res/lang/ja.json index f64da30..22049fc 100644 --- a/res/lang/ja.json +++ b/res/lang/ja.json @@ -136,9 +136,11 @@ "characters": "文字", "chat": "チャット", "chat_add_contact": "連絡先に追加", + "chat_awaiting_key": "返信待ち", "chat_cancel": "キャンセル", "chat_contact_added": "連絡先を追加しました — 連絡先で名前を変更できます", "chat_contact_request": "連絡リクエスト", + "chat_copy_address_tip": "クリックしてアドレスをコピー", "chat_emoji_search": "絵文字を検索", "chat_empty_hint": "まだ会話はありません。受信したメッセージはここに表示されます。", "chat_empty_start": "「新しい会話」から始めましょう。", @@ -181,6 +183,7 @@ "chat_toast_waiting_reply": "メッセージを送るには、この相手からの返信を待つ必要があります。", "chat_unhide": "再表示", "chat_unmute": "ミュート解除", + "chat_verify_key": "識別鍵 — 照合して確認", "chat_waiting_reply": "この相手からの返信を待っています — 返信があればメッセージを送れます。", "chat_you": "自分", "choose_icon": "アイコンを選択", diff --git a/res/lang/ko.json b/res/lang/ko.json index 469c616..4e17aec 100644 --- a/res/lang/ko.json +++ b/res/lang/ko.json @@ -136,9 +136,11 @@ "characters": "문자", "chat": "채팅", "chat_add_contact": "연락처 추가", + "chat_awaiting_key": "답장 대기 중", "chat_cancel": "취소", "chat_contact_added": "연락처 추가됨 — 연락처에서 이름을 변경하세요", "chat_contact_request": "연락 요청", + "chat_copy_address_tip": "클릭하여 주소 복사", "chat_emoji_search": "이모지 검색", "chat_empty_hint": "아직 대화가 없습니다. 받은 메시지가 여기에 표시됩니다.", "chat_empty_start": "\"새 대화\"로 시작하세요.", @@ -181,6 +183,7 @@ "chat_toast_waiting_reply": "상대방이 답장해야 메시지를 보낼 수 있습니다.", "chat_unhide": "다시 표시", "chat_unmute": "음소거 해제", + "chat_verify_key": "신원 키 — 비교하여 확인", "chat_waiting_reply": "상대방의 답장을 기다리는 중입니다 — 답장하면 메시지를 보낼 수 있습니다.", "chat_you": "나", "choose_icon": "아이콘 선택", diff --git a/res/lang/pt.json b/res/lang/pt.json index 665891c..0f88701 100644 --- a/res/lang/pt.json +++ b/res/lang/pt.json @@ -136,9 +136,11 @@ "characters": "caracteres", "chat": "Chat", "chat_add_contact": "Adicionar contato", + "chat_awaiting_key": "Aguardando resposta", "chat_cancel": "Cancelar", "chat_contact_added": "Contato adicionado — renomeie em Contatos", "chat_contact_request": "solicitação de contato", + "chat_copy_address_tip": "Clique para copiar o endereço", "chat_emoji_search": "Pesquisar emoji", "chat_empty_hint": "Nenhuma conversa ainda. As mensagens que você receber aparecerão aqui.", "chat_empty_start": "Inicie uma com \"Nova conversa\".", @@ -181,6 +183,7 @@ "chat_toast_waiting_reply": "Aguardando a resposta do contato antes que você possa enviar mensagens a ele.", "chat_unhide": "Mostrar", "chat_unmute": "Reativar som", + "chat_verify_key": "Chave de identidade — compare para verificar", "chat_waiting_reply": "Aguardando a resposta deste contato — você poderá enviar mensagens assim que ele responder.", "chat_you": "Você", "choose_icon": "Escolher Ícone", diff --git a/res/lang/ru.json b/res/lang/ru.json index 98afaed..3f13269 100644 --- a/res/lang/ru.json +++ b/res/lang/ru.json @@ -136,9 +136,11 @@ "characters": "символов", "chat": "Чат", "chat_add_contact": "Добавить контакт", + "chat_awaiting_key": "Ожидание ответа", "chat_cancel": "Отмена", "chat_contact_added": "Контакт добавлен — переименуйте его в Контактах", "chat_contact_request": "запрос контакта", + "chat_copy_address_tip": "Нажмите, чтобы скопировать адрес", "chat_emoji_search": "Поиск эмодзи", "chat_empty_hint": "Пока нет переписок. Полученные сообщения появятся здесь.", "chat_empty_start": "Начните новый с помощью «Новый разговор».", @@ -181,6 +183,7 @@ "chat_toast_waiting_reply": "Ожидание ответа от контакта — вы сможете писать ему только после этого.", "chat_unhide": "Показать", "chat_unmute": "Включить уведомления", + "chat_verify_key": "Ключ личности — сравните для проверки", "chat_waiting_reply": "Ожидание ответа от контакта — вы сможете писать ему, как только он ответит.", "chat_you": "Вы", "choose_icon": "Выбрать иконку", diff --git a/res/lang/zh.json b/res/lang/zh.json index b9acf67..26f9004 100644 --- a/res/lang/zh.json +++ b/res/lang/zh.json @@ -136,9 +136,11 @@ "characters": "字符", "chat": "聊天", "chat_add_contact": "添加联系人", + "chat_awaiting_key": "等待回复", "chat_cancel": "取消", "chat_contact_added": "已添加联系人——可在联系人中重命名", "chat_contact_request": "联系人请求", + "chat_copy_address_tip": "点击复制地址", "chat_emoji_search": "搜索表情", "chat_empty_hint": "暂无对话。您收到的消息将显示在此处。", "chat_empty_start": "点击\"新建会话\"开始。", @@ -181,6 +183,7 @@ "chat_toast_waiting_reply": "等待该联系人回复——对方回复后您即可向其发送消息。", "chat_unhide": "取消隐藏", "chat_unmute": "取消静音", + "chat_verify_key": "身份密钥 — 对比以验证", "chat_waiting_reply": "等待该联系人回复——对方回复后您即可向其发送消息。", "chat_you": "我", "choose_icon": "选择图标", diff --git a/src/ui/windows/chat_tab.cpp b/src/ui/windows/chat_tab.cpp index 94d46de..ae9711c 100644 --- a/src/ui/windows/chat_tab.cpp +++ b/src/ui/windows/chat_tab.cpp @@ -131,6 +131,19 @@ std::string initialOf(const std::string& name) { return name.substr(i, std::min(len, name.size() - i)); } +// Render a peer identity key (hex) as a groupable fingerprint for the verify tooltip: uppercased, +// spaced every 4 hex chars, wrapped every 16 (8 bytes) so two users can read it off line by line. +std::string keyFingerprint(const std::string& hex) { + std::string out; + out.reserve(hex.size() + hex.size() / 4); + for (std::size_t i = 0; i < hex.size(); ++i) { + const unsigned char c = static_cast(hex[i]); + out += static_cast(std::toupper(c)); + if ((i % 4) == 3 && i + 1 < hex.size()) out += ((i % 16) == 15) ? '\n' : ' '; + } + return out; +} + struct ConvSummary { std::string cid; std::string peerZaddr; @@ -255,22 +268,34 @@ void renderEmojiPickerOverlay(char* buf, std::size_t bufSize) { const std::string q = s_emoji_search; ImGui::BeginChild("##emojigrid", ImVec2(0, 0), false); - ImGui::PushFont(material::Type().subtitle1()); // larger + emoji-capable - const float cell = 32.0f * Layout::dpiScale(); - const float step = cell + ImGui::GetStyle().ItemSpacing.x; - const int perRow = std::max(1, static_cast(ImGui::GetContentRegionAvail().x / step)); + // Frameless cells tiled to the full pane width. The cell is kept tight (so the emoji fills it + // instead of floating in a big square); the leftover width is spread into the column gaps so the + // grid still spans edge-to-edge (Q12 polish — "scale to container width", denser variant). + ImFont* emojiFont = material::Type().subtitle1(); // emoji-capable + larger than body + const float dp = Layout::dpiScale(); + const float cell = 30.0f * dp; // tight square around the glyph + const float minGap = 5.0f * dp; + const float availW = ImGui::GetContentRegionAvail().x; + const int perRow = std::max(1, static_cast((availW + minGap) / (cell + minGap))); + const float spacing = perRow > 1 + ? std::clamp((availW - perRow * cell) / (perRow - 1), minGap, minGap * 3.0f) + : minGap; + material::IconButtonStyle es; + es.hoverBg = material::WithAlpha(material::OnSurface(), 34); + es.bgRounding = 8.0f * dp; + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(spacing, spacing)); int shown = 0; for (const auto& e : kEmoji) { if (!q.empty() && !containsCI(e.keywords, q)) continue; ImGui::PushID(shown); - if (ImGui::Button(e.glyph, ImVec2(cell, cell))) { + 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'; } } ImGui::PopID(); if (++shown % perRow != 0) ImGui::SameLine(); } - ImGui::PopFont(); + ImGui::PopStyleVar(); ImGui::EndChild(); } @@ -461,7 +486,7 @@ void RenderChatTab(App* app) ImGui::EndChild(); ImGui::PopStyleColor(); // list ChildBg tint (V6) - ImGui::SameLine(0.0f, 1.0f * Layout::dpiScale()); // tight single-line seam between the panes + ImGui::SameLine(0.0f, 10.0f * Layout::dpiScale()); // breathing gutter between the list and thread panes // ---- Right: selected conversation thread ---- ImGui::BeginChild("##ChatThread", ImVec2(0, avail.y), true); @@ -471,91 +496,183 @@ void RenderChatTab(App* app) if (sel) { app->markChatConversationSeen(sel->cid, sel->lastTs); // viewing the thread clears its unread (Q1) - // Header: peer avatar + name (V7). + // ── Header (V7 / Tier A+B): avatar + name on the left, a right-aligned icon toolbar, + // then a click-to-copy address with a key-verify lock (or a "waiting for reply" chip). + const float hdpi = Layout::dpiScale(); ImGui::PushFont(material::Type().subtitle1()); + const float nameH = ImGui::GetTextLineHeight(); + const float ib = nameH + 8.0f * hdpi; // icon-button square == header row height + const float gap = 3.0f * hdpi; + const float rowH = ib; + const float avR = nameH * 0.62f; + const ImVec2 hp = ImGui::GetCursorScreenPos(); + const float rightX = hp.x + ImGui::GetContentRegionAvail().x; + const float textX = hp.x + 2.0f * avR + 10.0f * hdpi; + ImDrawList* hdl = ImGui::GetWindowDrawList(); + const ImVec2 avC(hp.x + avR, hp.y + rowH * 0.5f); // avatar centered in the row + hdl->AddCircleFilled(avC, avR, avatarColor(sel->cid), 24); { - const float nameH = ImGui::GetTextLineHeight(); - const float avR = nameH * 0.6f; - const ImVec2 hp = ImGui::GetCursorScreenPos(); - ImDrawList* hdl = ImGui::GetWindowDrawList(); - const ImVec2 avC(hp.x + avR, hp.y + nameH * 0.5f); - hdl->AddCircleFilled(avC, avR, avatarColor(sel->cid), 24); const std::string init = initialOf(sel->peerName); const ImVec2 isz = ImGui::CalcTextSize(init.c_str()); hdl->AddText(ImVec2(avC.x - isz.x * 0.5f, avC.y - isz.y * 0.5f), IM_COL32(255, 255, 255, 235), init.c_str()); - ImGui::SetCursorScreenPos(ImVec2(hp.x + 2.0f * avR + 8.0f * Layout::dpiScale(), hp.y)); } + const bool hasAddr = !sel->peerZaddr.empty(); + const bool known = hasAddr && book.findByAddress(sel->peerZaddr) >= 0; + const bool muted = app->settings() && app->settings()->isChatMuted(sel->cid); + + // The toolbar's left edge is known up front (from the button count), so the name can be + // clipped to it — a long contact label can't overrun the icons (mirrors the list-pane clip). + const int nBtns = 3 + ((hasAddr && !known) ? 1 : 0); + const float toolbarLeft = rightX - (nBtns * ib + (nBtns - 1) * gap); + ImGui::SetCursorScreenPos(ImVec2(textX, hp.y + (rowH - nameH) * 0.5f)); // name centered in row + ImGui::PushClipRect(ImVec2(textX, hp.y), ImVec2(toolbarLeft - gap, hp.y + rowH), true); ImGui::TextUnformatted(sel->peerName.c_str()); + ImGui::PopClipRect(); ImGui::PopFont(); - if (!sel->peerZaddr.empty()) { - ImGui::PushFont(metaFont); - ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium()); - ImGui::TextUnformatted(shorten(sel->peerZaddr, 20, 12).c_str()); - ImGui::PopStyleColor(); - ImGui::PopFont(); - // Header actions: copy the peer z-address (Q3), and add them to contacts when unknown (Q2). - if (ImGui::SmallButton(TR("copy_address"))) ImGui::SetClipboardText(sel->peerZaddr.c_str()); - if (book.findByAddress(sel->peerZaddr) < 0) { - ImGui::SameLine(); - if (ImGui::SmallButton(TR("chat_add_contact"))) { + + // Right-aligned icon toolbar: [add contact?] · export · mute · hide. Each is a frameless + // IconButton with a hover pill + tooltip (the tooltip carries the wording the old text buttons had). + { + ImFont* ifont = material::Type().iconMed(); + material::IconButtonStyle base; + base.color = material::OnSurfaceMedium(); + base.hoverColor = material::OnSurface(); + base.hoverBg = material::WithAlpha(material::OnSurface(), 30); + base.bgRounding = 7.0f * hdpi; + + float bx = toolbarLeft; + const float by = hp.y; + + // Add to contacts — only when the peer isn't in the address book yet (Q2). + if (hasAddr && !known) { + ImGui::SetCursorScreenPos(ImVec2(bx, by)); + material::IconButtonStyle a = base; a.tooltip = TR("chat_add_contact"); + if (material::IconButton("##hdr_add", ICON_MD_PERSON_ADD, ifont, ImVec2(ib, ib), a)) { data::AddressBookEntry e(sel->peerName, sel->peerZaddr); // label = shortened addr; rename in Contacts if (book.addEntry(e)) Notifications::instance().success(TR("chat_contact_added")); else Notifications::instance().error(TR("address_book_exists")); } + bx += ib + gap; } - // Export the decrypted conversation to a plain-text file (Q11). Written with restricted - // permissions; the tooltip warns it's plaintext. - ImGui::SameLine(); - const bool exportClicked = ImGui::SmallButton(TR("chat_export")); - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("chat_export_warn")); - if (exportClicked) { - std::string content = "DragonX chat export\n"; - content += sel->peerName + " <" + sel->peerZaddr + ">\n"; - content += "cid: " + sel->cid + "\n\n"; - for (const auto& m : store.conversation(sel->cid)) { - const bool out = (m.direction == chat::ChatDirection::Outgoing); - content += "[" + formatTime(m.timestamp) + "] " + - (out ? std::string(TR("chat_you")) : sel->peerName) + ": " + m.body + "\n"; + // Export the decrypted conversation to a plain-text file (Q11); the tooltip warns it's plaintext. + { + ImGui::SetCursorScreenPos(ImVec2(bx, by)); + material::IconButtonStyle a = base; a.tooltip = TR("chat_export_warn"); + if (material::IconButton("##hdr_export", ICON_MD_FILE_DOWNLOAD, ifont, ImVec2(ib, ib), a)) { + std::string content = "DragonX chat export\n"; + content += sel->peerName + " <" + sel->peerZaddr + ">\n"; + content += "cid: " + sel->cid + "\n\n"; + for (const auto& m : store.conversation(sel->cid)) { + const bool out = (m.direction == chat::ChatDirection::Outgoing); + content += "[" + formatTime(m.timestamp) + "] " + + (out ? std::string(TR("chat_you")) : sel->peerName) + ": " + m.body + "\n"; + } + std::string safe; + for (char ch : sel->peerName) + safe += (std::isalnum(static_cast(ch)) ? ch : '_'); + if (safe.empty()) safe = "chat"; + const std::string path = util::Platform::getConfigDir() + "/dragonx-chat-" + safe + ".txt"; + if (util::Platform::writeFileAtomically(path, content, /*restrictPermissions=*/true)) + Notifications::instance().success(std::string(TR("chat_export_done")) + ": " + path); + else + Notifications::instance().error(TR("chat_export_failed")); } - std::string safe; - for (char ch : sel->peerName) - safe += (std::isalnum(static_cast(ch)) ? ch : '_'); - if (safe.empty()) safe = "chat"; - const std::string path = util::Platform::getConfigDir() + "/dragonx-chat-" + safe + ".txt"; - if (util::Platform::writeFileAtomically(path, content, /*restrictPermissions=*/true)) - Notifications::instance().success(std::string(TR("chat_export_done")) + ": " + path); - else - Notifications::instance().error(TR("chat_export_failed")); + bx += ib + gap; } // Mute toggle — muted conversations don't badge or toast (Q10). Persisted to settings. - ImGui::SameLine(); - const bool muted = app->settings() && app->settings()->isChatMuted(sel->cid); - if (ImGui::SmallButton(muted ? TR("chat_unmute") : TR("chat_mute")) && app->settings()) { - app->settings()->setChatMuted(sel->cid, !muted); - app->settings()->save(); + { + ImGui::SetCursorScreenPos(ImVec2(bx, by)); + material::IconButtonStyle a = base; + a.tooltip = muted ? TR("chat_unmute") : TR("chat_mute"); + if (muted) { a.color = material::Primary(); a.hoverColor = material::Primary(); } + if (material::IconButton("##hdr_mute", + muted ? ICON_MD_NOTIFICATIONS_OFF : ICON_MD_NOTIFICATIONS, + ifont, ImVec2(ib, ib), a) && app->settings()) { + app->settings()->setChatMuted(sel->cid, !muted); + app->settings()->save(); + } + bx += ib + gap; } // Hide / Unhide. Hiding drops it from the list (messages stay in the encrypted store); // Unhide (shown only for a hidden conversation while "Show hidden" is on) restores it. - ImGui::SameLine(); - if (ImGui::SmallButton(sel->hidden ? TR("chat_unhide") : TR("chat_hide")) && app->settings()) { - if (sel->hidden) { - app->settings()->setChatHidden(sel->cid, false); - app->settings()->save(); - } else { - app->markChatConversationSeen(sel->cid, sel->lastTs); // don't leave a phantom unread - app->settings()->setChatHidden(sel->cid, true); - app->settings()->save(); - s_selected_cid.clear(); - Notifications::instance().info(TR("chat_hidden_toast")); + { + ImGui::SetCursorScreenPos(ImVec2(bx, by)); + material::IconButtonStyle a = base; + a.tooltip = sel->hidden ? TR("chat_unhide") : TR("chat_hide"); + if (material::IconButton("##hdr_hide", + sel->hidden ? ICON_MD_VISIBILITY : ICON_MD_VISIBILITY_OFF, + ifont, ImVec2(ib, ib), a) && app->settings()) { + if (sel->hidden) { + app->settings()->setChatHidden(sel->cid, false); + app->settings()->save(); + } else { + app->markChatConversationSeen(sel->cid, sel->lastTs); // don't leave a phantom unread + app->settings()->setChatHidden(sel->cid, true); + app->settings()->save(); + s_selected_cid.clear(); + Notifications::instance().info(TR("chat_hidden_toast")); + } } } } + + // Row 2: a key-verify lock (Tier B) + click-to-copy address (Q3), or a "waiting for reply" chip. + ImGui::SetCursorScreenPos(ImVec2(textX, hp.y + rowH + 3.0f * hdpi)); + if (hasAddr) { + // Lock glyph → we hold the peer's identity key; the tooltip shows a comparable fingerprint. + if (!sel->peerPubKey.empty()) { + ImGui::PushFont(material::Type().iconSmall()); + ImGui::PushStyleColor(ImGuiCol_Text, material::WithAlpha(material::Primary(), 220)); + ImGui::TextUnformatted(ICON_MD_LOCK); + ImGui::PopStyleColor(); + ImGui::PopFont(); + if (ImGui::IsItemHovered()) + material::Tooltip("%s\n%s", TR("chat_verify_key"), keyFingerprint(sel->peerPubKey).c_str()); + ImGui::SameLine(0.0f, 5.0f * hdpi); + } + // Click-to-copy shortened address (replaces the old "Copy Full Address" button). + ImGui::PushFont(metaFont); + const std::string sa = shorten(sel->peerZaddr, 20, 12); + const ImVec2 tp = ImGui::GetCursorScreenPos(); + const ImVec2 tsz = ImGui::CalcTextSize(sa.c_str()); + const bool addrClicked = ImGui::InvisibleButton("##hdr_copyaddr", tsz); + const bool addrHov = ImGui::IsItemHovered(); + ImGui::GetWindowDrawList()->AddText( + tp, addrHov ? material::OnSurface() : material::OnSurfaceMedium(), sa.c_str()); + if (addrHov) { + ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + material::Tooltip("%s", TR("chat_copy_address_tip")); + } + if (addrClicked) { + ImGui::SetClipboardText(sel->peerZaddr.c_str()); + Notifications::instance().success(TR("copied")); + } + // Waiting-for-reply chip when the peer's identity key isn't known yet (Tier B). + if (sel->peerPubKey.empty()) { + ImGui::SameLine(0.0f, 8.0f * hdpi); + const char* wl = TR("chat_awaiting_key"); + const ImVec2 wsz = ImGui::CalcTextSize(wl); + const float px = 7.0f * hdpi, py = 2.0f * hdpi; + const ImVec2 cp = ImGui::GetCursorScreenPos(); + const ImVec2 pmax(cp.x + wsz.x + 2.0f * px, cp.y + wsz.y + 2.0f * py); + ImDrawList* cdl = ImGui::GetWindowDrawList(); + cdl->AddRectFilled(cp, pmax, material::WithAlpha(material::Primary(), 38), + (wsz.y + 2.0f * py) * 0.5f); + cdl->AddText(ImVec2(cp.x + px, cp.y + py), + material::WithAlpha(material::Primary(), 230), wl); + ImGui::Dummy(ImVec2(wsz.x + 2.0f * px, wsz.y + 2.0f * py)); + } + ImGui::PopFont(); + } else { + ImGui::Dummy(ImVec2(0.0f, metaSz)); // keep the header height stable when there's no address + } ImGui::Separator(); const float dp = Layout::dpiScale(); const float composerBoxH = ImGui::GetTextLineHeight() * 2.6f; // multi-line composer (Q6) - const float footerH = composerBoxH + ImGui::GetTextLineHeight() + 20.0f * dp; + // Footer = composer box + the emoji-toggle/counter row (30dp tall button) + padding. + const float footerH = composerBoxH + 30.0f * dp + 20.0f * dp; ImGui::BeginChild("##ChatMessages", ImVec2(0, ImGui::GetContentRegionAvail().y - footerH), false); bool atBottom = true; const ImVec2 msgWinMin = ImGui::GetWindowPos(); @@ -697,15 +814,26 @@ void RenderChatTab(App* app) ImGui::EndDisabled(); ImGui::PopStyleColor(3); // Emoji picker button (left) + byte counter (right) on one row. The picker opens as an - // overlay over the conversation-list pane (rendered there), not a floating window. - ImGui::PushFont(nameFont); // Body2 has emoji merged, so the glyph renders - const bool emojiClicked = ImGui::SmallButton(u8"🙂"); - ImGui::PopFont(); - if (emojiClicked) { s_show_emoji_picker = !s_show_emoji_picker; s_emoji_search[0] = '\0'; } + // overlay over the conversation-list pane (rendered there), not a floating window. The + // toggle is a larger frameless button (Tier A) that lights up while the picker is open. + const float emojiBtn = 30.0f * dp; + { + material::IconButtonStyle es; + es.hoverBg = material::WithAlpha(material::OnSurface(), 30); + es.bgRounding = 8.0f * dp; + if (s_show_emoji_picker) es.restBg = material::WithAlpha(material::Primary(), 46); + if (material::IconButton("##emojitoggle", u8"🙂", material::Type().subtitle1(), + ImVec2(emojiBtn, emojiBtn), es)) { + s_show_emoji_picker = !s_show_emoji_picker; + s_emoji_search[0] = '\0'; + } + } ImGui::SameLine(); - // Byte counter (Error tint over cap) with the over-cap label. + // Byte counter (Error tint over cap) with the over-cap label — vertically centered on the button. ImGui::PushFont(metaFont); ImGui::PushStyleColor(ImGuiCol_Text, overCap ? material::Error() : material::OnSurfaceMedium()); + ImGui::SetCursorPosY(ImGui::GetCursorPosY() + + std::max(0.0f, (emojiBtn - ImGui::GetTextLineHeight()) * 0.5f)); if (overCap) { ImGui::TextUnformatted(TR("chat_len_over")); ImGui::SameLine(); } const std::string counter = std::to_string(used) + " / " + std::to_string(kBodyMaxBytes); const float cw = metaFont->CalcTextSizeA(metaSz, FLT_MAX, 0.0f, counter.c_str()).x; diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index 277cc64..6a19978 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -260,6 +260,9 @@ void I18n::loadBuiltinEnglish() strings_["chat_hidden_toast"] = "Conversation hidden — a new message brings it back"; strings_["chat_pick_contact"] = "Choose from contacts\xE2\x80\xA6"; strings_["chat_no_z_contacts"] = "No shielded-address contacts yet"; + strings_["chat_copy_address_tip"] = "Click to copy address"; + strings_["chat_verify_key"] = "Identity key \xE2\x80\x94 compare to verify"; + strings_["chat_awaiting_key"] = "Waiting for reply"; // Seed-phrase backup (full-node) strings_["seed_backup_button"] = "Seed phrase"; strings_["tt_seed_backup"] = "Show and back up your wallet's 24-word recovery seed phrase";