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) <noreply@anthropic.com>
This commit is contained in:
2026-07-17 09:14:44 -05:00
parent 64b27db2ff
commit 63ed31d2ec
11 changed files with 226 additions and 71 deletions

Binary file not shown.

View File

@@ -136,9 +136,11 @@
"characters": "Zeichen", "characters": "Zeichen",
"chat": "Chat", "chat": "Chat",
"chat_add_contact": "Kontakt hinzufügen", "chat_add_contact": "Kontakt hinzufügen",
"chat_awaiting_key": "Warten auf Antwort",
"chat_cancel": "Abbrechen", "chat_cancel": "Abbrechen",
"chat_contact_added": "Kontakt hinzugefügt benenne ihn in Kontakte um", "chat_contact_added": "Kontakt hinzugefügt benenne ihn in Kontakte um",
"chat_contact_request": "kontaktanfrage", "chat_contact_request": "kontaktanfrage",
"chat_copy_address_tip": "Zum Kopieren der Adresse klicken",
"chat_emoji_search": "Emoji suchen", "chat_emoji_search": "Emoji suchen",
"chat_empty_hint": "Noch keine Unterhaltungen. Nachrichten, die du erhältst, erscheinen hier.", "chat_empty_hint": "Noch keine Unterhaltungen. Nachrichten, die du erhältst, erscheinen hier.",
"chat_empty_start": "Starte eine mit \"Neue Unterhaltung\".", "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_toast_waiting_reply": "Warte auf die Antwort des Kontakts, bevor du ihm schreiben kannst.",
"chat_unhide": "Einblenden", "chat_unhide": "Einblenden",
"chat_unmute": "Stummschaltung aufheben", "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_waiting_reply": "Warte auf die Antwort dieses Kontakts sobald er antwortet, kannst du ihm schreiben.",
"chat_you": "Du", "chat_you": "Du",
"choose_icon": "Symbol wählen", "choose_icon": "Symbol wählen",

View File

@@ -136,9 +136,11 @@
"characters": "caracteres", "characters": "caracteres",
"chat": "Chat", "chat": "Chat",
"chat_add_contact": "Añadir contacto", "chat_add_contact": "Añadir contacto",
"chat_awaiting_key": "Esperando respuesta",
"chat_cancel": "Cancelar", "chat_cancel": "Cancelar",
"chat_contact_added": "Contacto añadido: renómbralo en Contactos", "chat_contact_added": "Contacto añadido: renómbralo en Contactos",
"chat_contact_request": "solicitud de contacto", "chat_contact_request": "solicitud de contacto",
"chat_copy_address_tip": "Clic para copiar la dirección",
"chat_emoji_search": "Buscar emoji", "chat_emoji_search": "Buscar emoji",
"chat_empty_hint": "Aún no hay conversaciones. Los mensajes que recibas aparecerán aquí.", "chat_empty_hint": "Aún no hay conversaciones. Los mensajes que recibas aparecerán aquí.",
"chat_empty_start": "Inicia una con \"Nueva conversación\".", "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_toast_waiting_reply": "Espera a que este contacto responda antes de poder escribirle.",
"chat_unhide": "Mostrar", "chat_unhide": "Mostrar",
"chat_unmute": "Reactivar", "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_waiting_reply": "Esperando a que este contacto responda: podrás escribirle una vez lo haga.",
"chat_you": "Tú", "chat_you": "Tú",
"choose_icon": "Elegir Icono", "choose_icon": "Elegir Icono",

View File

@@ -136,9 +136,11 @@
"characters": "caractères", "characters": "caractères",
"chat": "Discussion", "chat": "Discussion",
"chat_add_contact": "Ajouter un contact", "chat_add_contact": "Ajouter un contact",
"chat_awaiting_key": "En attente de réponse",
"chat_cancel": "Annuler", "chat_cancel": "Annuler",
"chat_contact_added": "Contact ajouté — renommez-le dans Contacts", "chat_contact_added": "Contact ajouté — renommez-le dans Contacts",
"chat_contact_request": "demande de contact", "chat_contact_request": "demande de contact",
"chat_copy_address_tip": "Cliquer pour copier l'adresse",
"chat_emoji_search": "Rechercher un emoji", "chat_emoji_search": "Rechercher un emoji",
"chat_empty_hint": "Aucune conversation pour l'instant. Les messages que vous recevez apparaîtront ici.", "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 ».", "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_toast_waiting_reply": "En attente de la réponse de ce contact avant de pouvoir lui écrire.",
"chat_unhide": "Afficher", "chat_unhide": "Afficher",
"chat_unmute": "Réactiver", "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_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", "chat_you": "Vous",
"choose_icon": "Choisir une icône", "choose_icon": "Choisir une icône",

View File

@@ -136,9 +136,11 @@
"characters": "文字", "characters": "文字",
"chat": "チャット", "chat": "チャット",
"chat_add_contact": "連絡先に追加", "chat_add_contact": "連絡先に追加",
"chat_awaiting_key": "返信待ち",
"chat_cancel": "キャンセル", "chat_cancel": "キャンセル",
"chat_contact_added": "連絡先を追加しました — 連絡先で名前を変更できます", "chat_contact_added": "連絡先を追加しました — 連絡先で名前を変更できます",
"chat_contact_request": "連絡リクエスト", "chat_contact_request": "連絡リクエスト",
"chat_copy_address_tip": "クリックしてアドレスをコピー",
"chat_emoji_search": "絵文字を検索", "chat_emoji_search": "絵文字を検索",
"chat_empty_hint": "まだ会話はありません。受信したメッセージはここに表示されます。", "chat_empty_hint": "まだ会話はありません。受信したメッセージはここに表示されます。",
"chat_empty_start": "「新しい会話」から始めましょう。", "chat_empty_start": "「新しい会話」から始めましょう。",
@@ -181,6 +183,7 @@
"chat_toast_waiting_reply": "メッセージを送るには、この相手からの返信を待つ必要があります。", "chat_toast_waiting_reply": "メッセージを送るには、この相手からの返信を待つ必要があります。",
"chat_unhide": "再表示", "chat_unhide": "再表示",
"chat_unmute": "ミュート解除", "chat_unmute": "ミュート解除",
"chat_verify_key": "識別鍵 — 照合して確認",
"chat_waiting_reply": "この相手からの返信を待っています — 返信があればメッセージを送れます。", "chat_waiting_reply": "この相手からの返信を待っています — 返信があればメッセージを送れます。",
"chat_you": "自分", "chat_you": "自分",
"choose_icon": "アイコンを選択", "choose_icon": "アイコンを選択",

View File

@@ -136,9 +136,11 @@
"characters": "문자", "characters": "문자",
"chat": "채팅", "chat": "채팅",
"chat_add_contact": "연락처 추가", "chat_add_contact": "연락처 추가",
"chat_awaiting_key": "답장 대기 중",
"chat_cancel": "취소", "chat_cancel": "취소",
"chat_contact_added": "연락처 추가됨 — 연락처에서 이름을 변경하세요", "chat_contact_added": "연락처 추가됨 — 연락처에서 이름을 변경하세요",
"chat_contact_request": "연락 요청", "chat_contact_request": "연락 요청",
"chat_copy_address_tip": "클릭하여 주소 복사",
"chat_emoji_search": "이모지 검색", "chat_emoji_search": "이모지 검색",
"chat_empty_hint": "아직 대화가 없습니다. 받은 메시지가 여기에 표시됩니다.", "chat_empty_hint": "아직 대화가 없습니다. 받은 메시지가 여기에 표시됩니다.",
"chat_empty_start": "\"새 대화\"로 시작하세요.", "chat_empty_start": "\"새 대화\"로 시작하세요.",
@@ -181,6 +183,7 @@
"chat_toast_waiting_reply": "상대방이 답장해야 메시지를 보낼 수 있습니다.", "chat_toast_waiting_reply": "상대방이 답장해야 메시지를 보낼 수 있습니다.",
"chat_unhide": "다시 표시", "chat_unhide": "다시 표시",
"chat_unmute": "음소거 해제", "chat_unmute": "음소거 해제",
"chat_verify_key": "신원 키 — 비교하여 확인",
"chat_waiting_reply": "상대방의 답장을 기다리는 중입니다 — 답장하면 메시지를 보낼 수 있습니다.", "chat_waiting_reply": "상대방의 답장을 기다리는 중입니다 — 답장하면 메시지를 보낼 수 있습니다.",
"chat_you": "나", "chat_you": "나",
"choose_icon": "아이콘 선택", "choose_icon": "아이콘 선택",

View File

@@ -136,9 +136,11 @@
"characters": "caracteres", "characters": "caracteres",
"chat": "Chat", "chat": "Chat",
"chat_add_contact": "Adicionar contato", "chat_add_contact": "Adicionar contato",
"chat_awaiting_key": "Aguardando resposta",
"chat_cancel": "Cancelar", "chat_cancel": "Cancelar",
"chat_contact_added": "Contato adicionado — renomeie em Contatos", "chat_contact_added": "Contato adicionado — renomeie em Contatos",
"chat_contact_request": "solicitação de contato", "chat_contact_request": "solicitação de contato",
"chat_copy_address_tip": "Clique para copiar o endereço",
"chat_emoji_search": "Pesquisar emoji", "chat_emoji_search": "Pesquisar emoji",
"chat_empty_hint": "Nenhuma conversa ainda. As mensagens que você receber aparecerão aqui.", "chat_empty_hint": "Nenhuma conversa ainda. As mensagens que você receber aparecerão aqui.",
"chat_empty_start": "Inicie uma com \"Nova conversa\".", "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_toast_waiting_reply": "Aguardando a resposta do contato antes que você possa enviar mensagens a ele.",
"chat_unhide": "Mostrar", "chat_unhide": "Mostrar",
"chat_unmute": "Reativar som", "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_waiting_reply": "Aguardando a resposta deste contato — você poderá enviar mensagens assim que ele responder.",
"chat_you": "Você", "chat_you": "Você",
"choose_icon": "Escolher Ícone", "choose_icon": "Escolher Ícone",

View File

@@ -136,9 +136,11 @@
"characters": "символов", "characters": "символов",
"chat": "Чат", "chat": "Чат",
"chat_add_contact": "Добавить контакт", "chat_add_contact": "Добавить контакт",
"chat_awaiting_key": "Ожидание ответа",
"chat_cancel": "Отмена", "chat_cancel": "Отмена",
"chat_contact_added": "Контакт добавлен — переименуйте его в Контактах", "chat_contact_added": "Контакт добавлен — переименуйте его в Контактах",
"chat_contact_request": "запрос контакта", "chat_contact_request": "запрос контакта",
"chat_copy_address_tip": "Нажмите, чтобы скопировать адрес",
"chat_emoji_search": "Поиск эмодзи", "chat_emoji_search": "Поиск эмодзи",
"chat_empty_hint": "Пока нет переписок. Полученные сообщения появятся здесь.", "chat_empty_hint": "Пока нет переписок. Полученные сообщения появятся здесь.",
"chat_empty_start": "Начните новый с помощью «Новый разговор».", "chat_empty_start": "Начните новый с помощью «Новый разговор».",
@@ -181,6 +183,7 @@
"chat_toast_waiting_reply": "Ожидание ответа от контакта — вы сможете писать ему только после этого.", "chat_toast_waiting_reply": "Ожидание ответа от контакта — вы сможете писать ему только после этого.",
"chat_unhide": "Показать", "chat_unhide": "Показать",
"chat_unmute": "Включить уведомления", "chat_unmute": "Включить уведомления",
"chat_verify_key": "Ключ личности — сравните для проверки",
"chat_waiting_reply": "Ожидание ответа от контакта — вы сможете писать ему, как только он ответит.", "chat_waiting_reply": "Ожидание ответа от контакта — вы сможете писать ему, как только он ответит.",
"chat_you": "Вы", "chat_you": "Вы",
"choose_icon": "Выбрать иконку", "choose_icon": "Выбрать иконку",

View File

@@ -136,9 +136,11 @@
"characters": "字符", "characters": "字符",
"chat": "聊天", "chat": "聊天",
"chat_add_contact": "添加联系人", "chat_add_contact": "添加联系人",
"chat_awaiting_key": "等待回复",
"chat_cancel": "取消", "chat_cancel": "取消",
"chat_contact_added": "已添加联系人——可在联系人中重命名", "chat_contact_added": "已添加联系人——可在联系人中重命名",
"chat_contact_request": "联系人请求", "chat_contact_request": "联系人请求",
"chat_copy_address_tip": "点击复制地址",
"chat_emoji_search": "搜索表情", "chat_emoji_search": "搜索表情",
"chat_empty_hint": "暂无对话。您收到的消息将显示在此处。", "chat_empty_hint": "暂无对话。您收到的消息将显示在此处。",
"chat_empty_start": "点击\"新建会话\"开始。", "chat_empty_start": "点击\"新建会话\"开始。",
@@ -181,6 +183,7 @@
"chat_toast_waiting_reply": "等待该联系人回复——对方回复后您即可向其发送消息。", "chat_toast_waiting_reply": "等待该联系人回复——对方回复后您即可向其发送消息。",
"chat_unhide": "取消隐藏", "chat_unhide": "取消隐藏",
"chat_unmute": "取消静音", "chat_unmute": "取消静音",
"chat_verify_key": "身份密钥 — 对比以验证",
"chat_waiting_reply": "等待该联系人回复——对方回复后您即可向其发送消息。", "chat_waiting_reply": "等待该联系人回复——对方回复后您即可向其发送消息。",
"chat_you": "我", "chat_you": "我",
"choose_icon": "选择图标", "choose_icon": "选择图标",

View File

@@ -131,6 +131,19 @@ std::string initialOf(const std::string& name) {
return name.substr(i, std::min(len, name.size() - i)); 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<unsigned char>(hex[i]);
out += static_cast<char>(std::toupper(c));
if ((i % 4) == 3 && i + 1 < hex.size()) out += ((i % 16) == 15) ? '\n' : ' ';
}
return out;
}
struct ConvSummary { struct ConvSummary {
std::string cid; std::string cid;
std::string peerZaddr; std::string peerZaddr;
@@ -255,22 +268,34 @@ void renderEmojiPickerOverlay(char* buf, std::size_t bufSize) {
const std::string q = s_emoji_search; const std::string q = s_emoji_search;
ImGui::BeginChild("##emojigrid", ImVec2(0, 0), false); ImGui::BeginChild("##emojigrid", ImVec2(0, 0), false);
ImGui::PushFont(material::Type().subtitle1()); // larger + emoji-capable // Frameless cells tiled to the full pane width. The cell is kept tight (so the emoji fills it
const float cell = 32.0f * Layout::dpiScale(); // instead of floating in a big square); the leftover width is spread into the column gaps so the
const float step = cell + ImGui::GetStyle().ItemSpacing.x; // grid still spans edge-to-edge (Q12 polish — "scale to container width", denser variant).
const int perRow = std::max(1, static_cast<int>(ImGui::GetContentRegionAvail().x / step)); 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<int>((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; int shown = 0;
for (const auto& e : kEmoji) { for (const auto& e : kEmoji) {
if (!q.empty() && !containsCI(e.keywords, q)) continue; if (!q.empty() && !containsCI(e.keywords, q)) continue;
ImGui::PushID(shown); 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); 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 (cur + add < bufSize) { std::memcpy(buf + cur, e.glyph, add); buf[cur + add] = '\0'; }
} }
ImGui::PopID(); ImGui::PopID();
if (++shown % perRow != 0) ImGui::SameLine(); if (++shown % perRow != 0) ImGui::SameLine();
} }
ImGui::PopFont(); ImGui::PopStyleVar();
ImGui::EndChild(); ImGui::EndChild();
} }
@@ -461,7 +486,7 @@ void RenderChatTab(App* app)
ImGui::EndChild(); ImGui::EndChild();
ImGui::PopStyleColor(); // list ChildBg tint (V6) 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 ---- // ---- Right: selected conversation thread ----
ImGui::BeginChild("##ChatThread", ImVec2(0, avail.y), true); ImGui::BeginChild("##ChatThread", ImVec2(0, avail.y), true);
@@ -471,91 +496,183 @@ void RenderChatTab(App* app)
if (sel) { if (sel) {
app->markChatConversationSeen(sel->cid, sel->lastTs); // viewing the thread clears its unread (Q1) 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()); 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 std::string init = initialOf(sel->peerName);
const ImVec2 isz = ImGui::CalcTextSize(init.c_str()); const ImVec2 isz = ImGui::CalcTextSize(init.c_str());
hdl->AddText(ImVec2(avC.x - isz.x * 0.5f, avC.y - isz.y * 0.5f), hdl->AddText(ImVec2(avC.x - isz.x * 0.5f, avC.y - isz.y * 0.5f),
IM_COL32(255, 255, 255, 235), init.c_str()); 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::TextUnformatted(sel->peerName.c_str());
ImGui::PopClipRect();
ImGui::PopFont(); ImGui::PopFont();
if (!sel->peerZaddr.empty()) {
ImGui::PushFont(metaFont); // Right-aligned icon toolbar: [add contact?] · export · mute · hide. Each is a frameless
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium()); // IconButton with a hover pill + tooltip (the tooltip carries the wording the old text buttons had).
ImGui::TextUnformatted(shorten(sel->peerZaddr, 20, 12).c_str()); {
ImGui::PopStyleColor(); ImFont* ifont = material::Type().iconMed();
ImGui::PopFont(); material::IconButtonStyle base;
// Header actions: copy the peer z-address (Q3), and add them to contacts when unknown (Q2). base.color = material::OnSurfaceMedium();
if (ImGui::SmallButton(TR("copy_address"))) ImGui::SetClipboardText(sel->peerZaddr.c_str()); base.hoverColor = material::OnSurface();
if (book.findByAddress(sel->peerZaddr) < 0) { base.hoverBg = material::WithAlpha(material::OnSurface(), 30);
ImGui::SameLine(); base.bgRounding = 7.0f * hdpi;
if (ImGui::SmallButton(TR("chat_add_contact"))) {
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 data::AddressBookEntry e(sel->peerName, sel->peerZaddr); // label = shortened addr; rename in Contacts
if (book.addEntry(e)) Notifications::instance().success(TR("chat_contact_added")); if (book.addEntry(e)) Notifications::instance().success(TR("chat_contact_added"));
else Notifications::instance().error(TR("address_book_exists")); else Notifications::instance().error(TR("address_book_exists"));
} }
bx += ib + gap;
} }
// Export the decrypted conversation to a plain-text file (Q11). Written with restricted // Export the decrypted conversation to a plain-text file (Q11); the tooltip warns it's plaintext.
// permissions; the tooltip warns it's plaintext. {
ImGui::SameLine(); ImGui::SetCursorScreenPos(ImVec2(bx, by));
const bool exportClicked = ImGui::SmallButton(TR("chat_export")); material::IconButtonStyle a = base; a.tooltip = TR("chat_export_warn");
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("chat_export_warn")); if (material::IconButton("##hdr_export", ICON_MD_FILE_DOWNLOAD, ifont, ImVec2(ib, ib), a)) {
if (exportClicked) { std::string content = "DragonX chat export\n";
std::string content = "DragonX chat export\n"; content += sel->peerName + " <" + sel->peerZaddr + ">\n";
content += sel->peerName + " <" + sel->peerZaddr + ">\n"; content += "cid: " + sel->cid + "\n\n";
content += "cid: " + sel->cid + "\n\n"; for (const auto& m : store.conversation(sel->cid)) {
for (const auto& m : store.conversation(sel->cid)) { const bool out = (m.direction == chat::ChatDirection::Outgoing);
const bool out = (m.direction == chat::ChatDirection::Outgoing); content += "[" + formatTime(m.timestamp) + "] " +
content += "[" + formatTime(m.timestamp) + "] " + (out ? std::string(TR("chat_you")) : sel->peerName) + ": " + m.body + "\n";
(out ? std::string(TR("chat_you")) : sel->peerName) + ": " + m.body + "\n"; }
std::string safe;
for (char ch : sel->peerName)
safe += (std::isalnum(static_cast<unsigned char>(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; bx += ib + gap;
for (char ch : sel->peerName)
safe += (std::isalnum(static_cast<unsigned char>(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"));
} }
// Mute toggle — muted conversations don't badge or toast (Q10). Persisted to settings. // 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); ImGui::SetCursorScreenPos(ImVec2(bx, by));
if (ImGui::SmallButton(muted ? TR("chat_unmute") : TR("chat_mute")) && app->settings()) { material::IconButtonStyle a = base;
app->settings()->setChatMuted(sel->cid, !muted); a.tooltip = muted ? TR("chat_unmute") : TR("chat_mute");
app->settings()->save(); 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); // 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. // 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()) { ImGui::SetCursorScreenPos(ImVec2(bx, by));
if (sel->hidden) { material::IconButtonStyle a = base;
app->settings()->setChatHidden(sel->cid, false); a.tooltip = sel->hidden ? TR("chat_unhide") : TR("chat_hide");
app->settings()->save(); if (material::IconButton("##hdr_hide",
} else { sel->hidden ? ICON_MD_VISIBILITY : ICON_MD_VISIBILITY_OFF,
app->markChatConversationSeen(sel->cid, sel->lastTs); // don't leave a phantom unread ifont, ImVec2(ib, ib), a) && app->settings()) {
app->settings()->setChatHidden(sel->cid, true); if (sel->hidden) {
app->settings()->save(); app->settings()->setChatHidden(sel->cid, false);
s_selected_cid.clear(); app->settings()->save();
Notifications::instance().info(TR("chat_hidden_toast")); } 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(); ImGui::Separator();
const float dp = Layout::dpiScale(); const float dp = Layout::dpiScale();
const float composerBoxH = ImGui::GetTextLineHeight() * 2.6f; // multi-line composer (Q6) 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); ImGui::BeginChild("##ChatMessages", ImVec2(0, ImGui::GetContentRegionAvail().y - footerH), false);
bool atBottom = true; bool atBottom = true;
const ImVec2 msgWinMin = ImGui::GetWindowPos(); const ImVec2 msgWinMin = ImGui::GetWindowPos();
@@ -697,15 +814,26 @@ void RenderChatTab(App* app)
ImGui::EndDisabled(); ImGui::EndDisabled();
ImGui::PopStyleColor(3); ImGui::PopStyleColor(3);
// Emoji picker button (left) + byte counter (right) on one row. The picker opens as an // 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. // overlay over the conversation-list pane (rendered there), not a floating window. The
ImGui::PushFont(nameFont); // Body2 has emoji merged, so the glyph renders // toggle is a larger frameless button (Tier A) that lights up while the picker is open.
const bool emojiClicked = ImGui::SmallButton(u8"🙂"); const float emojiBtn = 30.0f * dp;
ImGui::PopFont(); {
if (emojiClicked) { s_show_emoji_picker = !s_show_emoji_picker; s_emoji_search[0] = '\0'; } 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(); 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::PushFont(metaFont);
ImGui::PushStyleColor(ImGuiCol_Text, overCap ? material::Error() : material::OnSurfaceMedium()); 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(); } if (overCap) { ImGui::TextUnformatted(TR("chat_len_over")); ImGui::SameLine(); }
const std::string counter = std::to_string(used) + " / " + std::to_string(kBodyMaxBytes); 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; const float cw = metaFont->CalcTextSizeA(metaSz, FLT_MAX, 0.0f, counter.c_str()).x;

View File

@@ -260,6 +260,9 @@ void I18n::loadBuiltinEnglish()
strings_["chat_hidden_toast"] = "Conversation hidden — a new message brings it back"; strings_["chat_hidden_toast"] = "Conversation hidden — a new message brings it back";
strings_["chat_pick_contact"] = "Choose from contacts\xE2\x80\xA6"; strings_["chat_pick_contact"] = "Choose from contacts\xE2\x80\xA6";
strings_["chat_no_z_contacts"] = "No shielded-address contacts yet"; 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) // Seed-phrase backup (full-node)
strings_["seed_backup_button"] = "Seed phrase"; strings_["seed_backup_button"] = "Seed phrase";
strings_["tt_seed_backup"] = "Show and back up your wallet's 24-word recovery seed phrase"; strings_["tt_seed_backup"] = "Show and back up your wallet's 24-word recovery seed phrase";