diff --git a/res/fonts/NotoSansCJK-Subset.ttf b/res/fonts/NotoSansCJK-Subset.ttf index 3a521b7..2e32b5e 100644 Binary files a/res/fonts/NotoSansCJK-Subset.ttf and b/res/fonts/NotoSansCJK-Subset.ttf differ 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";