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:
Binary file not shown.
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "アイコンを選択",
|
||||||
|
|||||||
@@ -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": "아이콘 선택",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "Выбрать иконку",
|
||||||
|
|||||||
@@ -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": "选择图标",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
Reference in New Issue
Block a user