From 2c909d35ea5df95d666e34fd8234daf3a3df2e83 Mon Sep 17 00:00:00 2001 From: DanS Date: Fri, 17 Jul 2026 18:45:19 -0500 Subject: [PATCH] feat(chat): message-list rework, customization surface, smooth scroll Chat tab overhaul: - Header/layout: single-row header (name + key-lock + compact click-to-copy address + right-aligned icon toolbar with a settings "notch"), composer moved below the message box (emoji toggle left of a bottom-flush input), tightened list-pane controls. - Message list: per-day date separators (Today/Yesterday/date), time-only group headers, tight same-sender grouping with iMessage-style merged corners, per-run peer avatar, delivery status (clock -> check), hover-reveal per-message time. Message base ~18px. - Customization: a settings "notch" gear opens a modal (also under Settings -> Chat & Contacts) with segmented controls for emoji style / bubble style / density / timestamps, sliders for poll rate + text size, a bubble-accent dropdown, Enter-to-send. Bubble style/accent/density/text-size/timestamps all applied live in the message loop. - Settings: app-wide clock-format control lives in Settings -> General; chat keeps a per-tab override. Both chat panes now use the app's smooth wheel-scroll. - i18n: new strings across all 8 languages; CJK subset rebuilt for the added glyphs. Inline contact rename, hide/mute, 0-conf fast-scan and the export path are carried through. Co-Authored-By: Claude Opus 4.8 (1M context) --- res/fonts/NotoSansCJK-Subset.ttf | Bin 639684 -> 650892 bytes res/lang/de.json | 43 +- res/lang/es.json | 43 +- res/lang/fr.json | 43 +- res/lang/ja.json | 43 +- res/lang/ko.json | 43 +- res/lang/pt.json | 43 +- res/lang/ru.json | 43 +- res/lang/zh.json | 43 +- src/ui/pages/settings_page.cpp | 54 ++ src/ui/windows/chat_tab.cpp | 818 +++++++++++++++++++++++-------- src/ui/windows/chat_tab.h | 12 + src/util/i18n.cpp | 44 +- 13 files changed, 1060 insertions(+), 212 deletions(-) diff --git a/res/fonts/NotoSansCJK-Subset.ttf b/res/fonts/NotoSansCJK-Subset.ttf index 2e32b5e198012eab4466866b6fa64386771b8a70..1b494ae45efc59e89c31b7f251689d2c93010f42 100644 GIT binary patch delta 34120 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 diff --git a/res/lang/de.json b/res/lang/de.json index 3979d02..c831a6e 100644 --- a/res/lang/de.json +++ b/res/lang/de.json @@ -135,12 +135,25 @@ "change_pass_title": "Passphrase ändern", "characters": "Zeichen", "chat": "Chat", + "chat_accent_amber": "Bernstein", + "chat_accent_blue": "Blau", + "chat_accent_green": "Grün", + "chat_accent_pink": "Rosa", + "chat_accent_purple": "Lila", + "chat_accent_theme": "Design", "chat_add_contact": "Kontakt hinzufügen", "chat_awaiting_key": "Warten auf Antwort", + "chat_bubble_minimal": "Minimal", + "chat_bubble_rounded": "Abgerundet", + "chat_bubble_square": "Eckig", "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_density_comfortable": "Komfortabel", + "chat_density_compact": "Kompakt", + "chat_emoji_color": "Farbig", + "chat_emoji_mono": "Monochrom", "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\".", @@ -156,21 +169,39 @@ "chat_len_over": "Nachricht zu lang", "chat_locked_hint": "Entsperre deine Wallet, um deine Chats zu laden.", "chat_mute": "Stummschalten", - "chat_new_button": "Neue Unterhaltung", + "chat_new_button": "Neuer Chat", "chat_new_message": "Nachricht", "chat_new_message_toast": "Neue verschlüsselte Chat-Nachricht", "chat_new_send": "Anfrage senden", - "chat_new_title": "Neue Unterhaltung", + "chat_new_title": "Neuer Chat", "chat_new_zaddr": "z-Adresse des Empfängers", "chat_no_matches": "Keine Unterhaltungen entsprechen deiner Suche.", "chat_no_z_contacts": "Noch keine Kontakte mit geschützter Adresse", + "chat_opt_bubble_accent": "Blasenfarbe", + "chat_opt_bubble_style": "Blasenstil", + "chat_opt_density": "Nachrichtendichte", + "chat_opt_emoji": "Emoji-Stil", + "chat_opt_enter_sends": "Eingabetaste sendet", + "chat_opt_font_size": "Textgröße", + "chat_opt_global_clock": "Globales Uhrzeitformat", + "chat_opt_poll": "Abrufrate", + "chat_opt_timestamp": "Zeitstempel", "chat_pick_contact": "Aus Kontakten wählen…", + "chat_rename": "Kontakt umbenennen", + "chat_rename_hint": "Kontaktname", + "chat_renamed": "Kontakt umbenannt", "chat_retry": "Wiederholen", "chat_search": "Unterhaltungen durchsuchen", + "chat_sec_appearance": "DARSTELLUNG", + "chat_sec_messaging": "NACHRICHTEN", "chat_select_hint": "Wähle eine Unterhaltung aus, um sie anzuzeigen.", "chat_send": "Senden", "chat_send_failed": "nicht gesendet", "chat_sending": "senden…", + "chat_settings_done": "Fertig", + "chat_settings_section": "CHAT & KONTAKTE", + "chat_settings_tip": "Chat anpassen", + "chat_settings_title": "Chat-Einstellungen", "chat_show_hidden": "Ausgeblendete anzeigen", "chat_time_now": "jetzt", "chat_toast_compose_failed": "Nachricht konnte nicht erstellt werden (zu lang?).", @@ -181,10 +212,16 @@ "chat_toast_request_compose_failed": "Kontaktanfrage konnte nicht erstellt werden (ungültige Adresse / ungültiger Text?).", "chat_toast_request_queued": "Kontaktanfrage in Warteschlange.", "chat_toast_waiting_reply": "Warte auf die Antwort des Kontakts, bevor du ihm schreiben kannst.", + "chat_today": "Heute", + "chat_ts_12h": "12-Stunden", + "chat_ts_24h": "24-Stunden", + "chat_ts_global": "Global folgen", + "chat_ts_global_short": "Global", "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_yesterday": "Gestern", "chat_you": "Du", "choose_icon": "Symbol wählen", "clear": "Leeren", @@ -196,6 +233,7 @@ "click_copy_address": "Klicken zum Kopieren der Adresse", "click_copy_uri": "Klicken zum Kopieren der URI", "click_to_copy": "Klicken zum Kopieren", + "clock_format": "Uhrzeitformat", "close": "Schließen", "conf_count": "%d Best.", "confirm_and_send": "Bestätigen & Senden", @@ -1388,6 +1426,7 @@ "tt_change_pass": "Die Wallet-Verschlüsselungspassphrase ändern", "tt_change_pin": "Ihre Entsperr-PIN ändern", "tt_clear_ztx": "Lokal zwischengespeicherten Z-Transaktionsverlauf löschen", + "tt_clock_format": "24- oder 12-Stunden-Uhr, app-weit. Der Chat-Tab kann sie überschreiben.", "tt_custom_fees": "Manuelle Gebühreneingabe beim Senden von Transaktionen aktivieren", "tt_custom_theme": "Benutzerdefiniertes Theme aktiv", "tt_daemon_install_bundled": "Node stoppen, den installierten dragonxd mit der in diesem Wallet-Build enthaltenen Version überschreiben und dann neu starten", diff --git a/res/lang/es.json b/res/lang/es.json index 7a551f2..cb2733c 100644 --- a/res/lang/es.json +++ b/res/lang/es.json @@ -135,12 +135,25 @@ "change_pass_title": "Cambiar frase de contraseña", "characters": "caracteres", "chat": "Chat", + "chat_accent_amber": "Ámbar", + "chat_accent_blue": "Azul", + "chat_accent_green": "Verde", + "chat_accent_pink": "Rosa", + "chat_accent_purple": "Morado", + "chat_accent_theme": "Tema", "chat_add_contact": "Añadir contacto", "chat_awaiting_key": "Esperando respuesta", + "chat_bubble_minimal": "Mínima", + "chat_bubble_rounded": "Redondeada", + "chat_bubble_square": "Cuadrada", "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_density_comfortable": "Cómoda", + "chat_density_compact": "Compacta", + "chat_emoji_color": "Color", + "chat_emoji_mono": "Monocromo", "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\".", @@ -156,21 +169,39 @@ "chat_len_over": "Mensaje demasiado largo", "chat_locked_hint": "Desbloquea tu monedero para cargar tus chats.", "chat_mute": "Silenciar", - "chat_new_button": "Nueva conversación", + "chat_new_button": "Nuevo chat", "chat_new_message": "Mensaje", "chat_new_message_toast": "Nuevo mensaje de chat cifrado", "chat_new_send": "Enviar solicitud", - "chat_new_title": "Nueva conversación", + "chat_new_title": "Nuevo chat", "chat_new_zaddr": "Dirección z del destinatario", "chat_no_matches": "Ninguna conversación coincide con tu búsqueda.", "chat_no_z_contacts": "Aún no hay contactos con dirección blindada", + "chat_opt_bubble_accent": "Color de burbuja", + "chat_opt_bubble_style": "Estilo de burbuja", + "chat_opt_density": "Densidad de mensajes", + "chat_opt_emoji": "Estilo de emoji", + "chat_opt_enter_sends": "Enter envía el mensaje", + "chat_opt_font_size": "Tamaño del texto", + "chat_opt_global_clock": "Formato de reloj global", + "chat_opt_poll": "Frecuencia de sondeo", + "chat_opt_timestamp": "Marcas de tiempo", "chat_pick_contact": "Elegir de contactos…", + "chat_rename": "Renombrar contacto", + "chat_rename_hint": "Nombre del contacto", + "chat_renamed": "Contacto renombrado", "chat_retry": "Reintentar", "chat_search": "Buscar conversaciones", + "chat_sec_appearance": "APARIENCIA", + "chat_sec_messaging": "MENSAJES", "chat_select_hint": "Selecciona una conversación para verla.", "chat_send": "Enviar", "chat_send_failed": "no enviado", "chat_sending": "enviando…", + "chat_settings_done": "Listo", + "chat_settings_section": "CHAT Y CONTACTOS", + "chat_settings_tip": "Personalizar chat", + "chat_settings_title": "Ajustes de chat", "chat_show_hidden": "Ver ocultos", "chat_time_now": "ahora", "chat_toast_compose_failed": "No se pudo componer el mensaje (¿demasiado largo?).", @@ -181,10 +212,16 @@ "chat_toast_request_compose_failed": "No se pudo componer la solicitud de contacto (¿dirección o texto no válidos?).", "chat_toast_request_queued": "Solicitud de contacto en cola.", "chat_toast_waiting_reply": "Espera a que este contacto responda antes de poder escribirle.", + "chat_today": "Hoy", + "chat_ts_12h": "12 horas", + "chat_ts_24h": "24 horas", + "chat_ts_global": "Seguir global", + "chat_ts_global_short": "Global", "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_yesterday": "Ayer", "chat_you": "Tú", "choose_icon": "Elegir Icono", "clear": "Limpiar", @@ -196,6 +233,7 @@ "click_copy_address": "Clic para copiar dirección", "click_copy_uri": "Clic para copiar URI", "click_to_copy": "Clic para copiar", + "clock_format": "Formato de hora", "close": "Cerrar", "conf_count": "%d conf", "confirm_and_send": "Confirmar y Enviar", @@ -1388,6 +1426,7 @@ "tt_change_pass": "Cambiar la contraseña de cifrado de la billetera", "tt_change_pin": "Cambiar su PIN de desbloqueo", "tt_clear_ztx": "Eliminar historial de z-transacciones en caché local", + "tt_clock_format": "Reloj de 24 o 12 horas, en toda la app. El chat puede anularlo.", "tt_custom_fees": "Habilitar entrada manual de comisiones al enviar transacciones", "tt_custom_theme": "Tema personalizado activo", "tt_daemon_install_bundled": "Detiene el nodo, sobrescribe el dragonxd instalado con la versión incluida en esta compilación de la cartera y luego lo reinicia", diff --git a/res/lang/fr.json b/res/lang/fr.json index 685a5ba..c15d8ec 100644 --- a/res/lang/fr.json +++ b/res/lang/fr.json @@ -135,12 +135,25 @@ "change_pass_title": "Changer la phrase secrète", "characters": "caractères", "chat": "Discussion", + "chat_accent_amber": "Ambre", + "chat_accent_blue": "Bleu", + "chat_accent_green": "Vert", + "chat_accent_pink": "Rose", + "chat_accent_purple": "Violet", + "chat_accent_theme": "Thème", "chat_add_contact": "Ajouter un contact", "chat_awaiting_key": "En attente de réponse", + "chat_bubble_minimal": "Minimale", + "chat_bubble_rounded": "Arrondie", + "chat_bubble_square": "Carrée", "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_density_comfortable": "Confortable", + "chat_density_compact": "Compacte", + "chat_emoji_color": "Couleur", + "chat_emoji_mono": "Monochrome", "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 ».", @@ -156,21 +169,39 @@ "chat_len_over": "Message trop long", "chat_locked_hint": "Déverrouillez votre portefeuille pour charger vos discussions.", "chat_mute": "Muet", - "chat_new_button": "Nouvelle conversation", + "chat_new_button": "Nouvelle discussion", "chat_new_message": "Message", "chat_new_message_toast": "Nouveau message chiffré", "chat_new_send": "Envoyer la demande", - "chat_new_title": "Nouvelle conversation", + "chat_new_title": "Nouvelle discussion", "chat_new_zaddr": "Adresse Z du destinataire", "chat_no_matches": "Aucune conversation ne correspond à votre recherche.", "chat_no_z_contacts": "Aucun contact avec adresse blindée pour l'instant", + "chat_opt_bubble_accent": "Couleur de bulle", + "chat_opt_bubble_style": "Style de bulle", + "chat_opt_density": "Densité des messages", + "chat_opt_emoji": "Style d'emoji", + "chat_opt_enter_sends": "Entrée envoie le message", + "chat_opt_font_size": "Taille du texte", + "chat_opt_global_clock": "Format d'horloge global", + "chat_opt_poll": "Fréquence d'actualisation", + "chat_opt_timestamp": "Horodatage", "chat_pick_contact": "Choisir dans les contacts…", + "chat_rename": "Renommer le contact", + "chat_rename_hint": "Nom du contact", + "chat_renamed": "Contact renommé", "chat_retry": "Réessayer", "chat_search": "Rechercher des conversations", + "chat_sec_appearance": "APPARENCE", + "chat_sec_messaging": "MESSAGERIE", "chat_select_hint": "Sélectionnez une conversation pour l'afficher.", "chat_send": "Envoyer", "chat_send_failed": "non envoyé", "chat_sending": "envoi…", + "chat_settings_done": "Terminé", + "chat_settings_section": "CHAT ET CONTACTS", + "chat_settings_tip": "Personnaliser le chat", + "chat_settings_title": "Paramètres du chat", "chat_show_hidden": "Afficher masqués", "chat_time_now": "à l'instant", "chat_toast_compose_failed": "Impossible de composer le message (trop long ?).", @@ -181,10 +212,16 @@ "chat_toast_request_compose_failed": "Impossible de composer la demande de contact (adresse / texte invalide ?).", "chat_toast_request_queued": "Demande de contact mise en file d'attente.", "chat_toast_waiting_reply": "En attente de la réponse de ce contact avant de pouvoir lui écrire.", + "chat_today": "Aujourd'hui", + "chat_ts_12h": "12 heures", + "chat_ts_24h": "24 heures", + "chat_ts_global": "Suivre global", + "chat_ts_global_short": "Global", "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_yesterday": "Hier", "chat_you": "Vous", "choose_icon": "Choisir une icône", "clear": "Effacer", @@ -196,6 +233,7 @@ "click_copy_address": "Cliquez pour copier l'adresse", "click_copy_uri": "Cliquez pour copier l'URI", "click_to_copy": "Cliquez pour copier", + "clock_format": "Format d'horloge", "close": "Fermer", "conf_count": "%d conf.", "confirm_and_send": "Confirmer & Envoyer", @@ -1388,6 +1426,7 @@ "tt_change_pass": "Changer la phrase secrète de chiffrement du portefeuille", "tt_change_pin": "Changer votre PIN de déverrouillage", "tt_clear_ztx": "Supprimer l'historique des z-transactions mis en cache localement", + "tt_clock_format": "Horloge 24 h ou 12 h, dans toute l'app. Le chat peut la remplacer.", "tt_custom_fees": "Activer la saisie manuelle des frais lors de l'envoi de transactions", "tt_custom_theme": "Thème personnalisé actif", "tt_daemon_install_bundled": "Arrêter le nœud, remplacer le dragonxd installé par la version intégrée dans cette version du portefeuille, puis redémarrer", diff --git a/res/lang/ja.json b/res/lang/ja.json index 22049fc..832a853 100644 --- a/res/lang/ja.json +++ b/res/lang/ja.json @@ -135,12 +135,25 @@ "change_pass_title": "パスフレーズを変更", "characters": "文字", "chat": "チャット", + "chat_accent_amber": "琥珀", + "chat_accent_blue": "青", + "chat_accent_green": "緑", + "chat_accent_pink": "ピンク", + "chat_accent_purple": "紫", + "chat_accent_theme": "テーマ", "chat_add_contact": "連絡先に追加", "chat_awaiting_key": "返信待ち", + "chat_bubble_minimal": "ミニマル", + "chat_bubble_rounded": "角丸", + "chat_bubble_square": "角ばった", "chat_cancel": "キャンセル", "chat_contact_added": "連絡先を追加しました — 連絡先で名前を変更できます", "chat_contact_request": "連絡リクエスト", "chat_copy_address_tip": "クリックしてアドレスをコピー", + "chat_density_comfortable": "ゆったり", + "chat_density_compact": "コンパクト", + "chat_emoji_color": "カラー", + "chat_emoji_mono": "モノクロ", "chat_emoji_search": "絵文字を検索", "chat_empty_hint": "まだ会話はありません。受信したメッセージはここに表示されます。", "chat_empty_start": "「新しい会話」から始めましょう。", @@ -156,21 +169,39 @@ "chat_len_over": "メッセージが長すぎます", "chat_locked_hint": "チャットを読み込むにはウォレットのロックを解除してください。", "chat_mute": "ミュート", - "chat_new_button": "新しい会話", + "chat_new_button": "新しいチャット", "chat_new_message": "メッセージ", "chat_new_message_toast": "新しい暗号化チャットメッセージ", "chat_new_send": "リクエストを送信", - "chat_new_title": "新しい会話", + "chat_new_title": "新しいチャット", "chat_new_zaddr": "宛先Zアドレス", "chat_no_matches": "検索に一致する会話がありません。", "chat_no_z_contacts": "シールドアドレスの連絡先はまだありません", + "chat_opt_bubble_accent": "吹き出しの色", + "chat_opt_bubble_style": "吹き出しスタイル", + "chat_opt_density": "メッセージ密度", + "chat_opt_emoji": "絵文字スタイル", + "chat_opt_enter_sends": "Enterで送信", + "chat_opt_font_size": "文字サイズ", + "chat_opt_global_clock": "全体の時刻形式", + "chat_opt_poll": "取得間隔", + "chat_opt_timestamp": "タイムスタンプ", "chat_pick_contact": "連絡先から選択…", + "chat_rename": "連絡先の名前を変更", + "chat_rename_hint": "連絡先名", + "chat_renamed": "連絡先の名前を変更しました", "chat_retry": "再送信", "chat_search": "会話を検索", + "chat_sec_appearance": "外観", + "chat_sec_messaging": "メッセージ", "chat_select_hint": "表示する会話を選択してください。", "chat_send": "送信", "chat_send_failed": "未送信", "chat_sending": "送信中…", + "chat_settings_done": "完了", + "chat_settings_section": "チャットと連絡先", + "chat_settings_tip": "チャットのカスタマイズ", + "chat_settings_title": "チャット設定", "chat_show_hidden": "非表示を表示", "chat_time_now": "たった今", "chat_toast_compose_failed": "メッセージを作成できませんでした(長すぎませんか?)。", @@ -181,10 +212,16 @@ "chat_toast_request_compose_failed": "連絡リクエストを作成できませんでした(アドレスまたはテキストが無効?)。", "chat_toast_request_queued": "連絡リクエストを送信待ちに追加しました。", "chat_toast_waiting_reply": "メッセージを送るには、この相手からの返信を待つ必要があります。", + "chat_today": "今日", + "chat_ts_12h": "12時間", + "chat_ts_24h": "24時間", + "chat_ts_global": "全体設定に従う", + "chat_ts_global_short": "全体", "chat_unhide": "再表示", "chat_unmute": "ミュート解除", "chat_verify_key": "識別鍵 — 照合して確認", "chat_waiting_reply": "この相手からの返信を待っています — 返信があればメッセージを送れます。", + "chat_yesterday": "昨日", "chat_you": "自分", "choose_icon": "アイコンを選択", "clear": "クリア", @@ -196,6 +233,7 @@ "click_copy_address": "クリックしてアドレスをコピー", "click_copy_uri": "クリックしてURIをコピー", "click_to_copy": "クリックしてコピー", + "clock_format": "時刻形式", "close": "閉じる", "conf_count": "%d 確認", "confirm_and_send": "確認して送金", @@ -1388,6 +1426,7 @@ "tt_change_pass": "ウォレットの暗号化パスフレーズを変更", "tt_change_pin": "アンロック PIN を変更", "tt_clear_ztx": "ローカルにキャッシュされた z-トランザクション履歴を削除", + "tt_clock_format": "24時間または12時間表示(アプリ全体)。チャットで上書きできます。", "tt_custom_fees": "トランザクション送信時に手動手数料入力を有効化", "tt_custom_theme": "カスタムテーマがアクティブ", "tt_daemon_install_bundled": "ノードを停止し、インストール済みの dragonxd をこのウォレットビルドにバンドルされたバージョンで上書きしてから再起動します", diff --git a/res/lang/ko.json b/res/lang/ko.json index 4e17aec..b516fb4 100644 --- a/res/lang/ko.json +++ b/res/lang/ko.json @@ -135,12 +135,25 @@ "change_pass_title": "암호 변경", "characters": "문자", "chat": "채팅", + "chat_accent_amber": "황색", + "chat_accent_blue": "파랑", + "chat_accent_green": "초록", + "chat_accent_pink": "분홍", + "chat_accent_purple": "보라", + "chat_accent_theme": "테마", "chat_add_contact": "연락처 추가", "chat_awaiting_key": "답장 대기 중", + "chat_bubble_minimal": "미니멀", + "chat_bubble_rounded": "둥근", + "chat_bubble_square": "각진", "chat_cancel": "취소", "chat_contact_added": "연락처 추가됨 — 연락처에서 이름을 변경하세요", "chat_contact_request": "연락 요청", "chat_copy_address_tip": "클릭하여 주소 복사", + "chat_density_comfortable": "편안하게", + "chat_density_compact": "촘촘하게", + "chat_emoji_color": "컬러", + "chat_emoji_mono": "단색", "chat_emoji_search": "이모지 검색", "chat_empty_hint": "아직 대화가 없습니다. 받은 메시지가 여기에 표시됩니다.", "chat_empty_start": "\"새 대화\"로 시작하세요.", @@ -156,21 +169,39 @@ "chat_len_over": "메시지가 너무 깁니다", "chat_locked_hint": "채팅을 불러오려면 지갑 잠금을 해제하세요.", "chat_mute": "음소거", - "chat_new_button": "새 대화", + "chat_new_button": "새 채팅", "chat_new_message": "메시지", "chat_new_message_toast": "새 암호화 채팅 메시지", "chat_new_send": "요청 보내기", - "chat_new_title": "새 대화", + "chat_new_title": "새 채팅", "chat_new_zaddr": "받는 사람 z-주소", "chat_no_matches": "검색과 일치하는 대화가 없습니다.", "chat_no_z_contacts": "보호 주소 연락처가 아직 없습니다", + "chat_opt_bubble_accent": "말풍선 색상", + "chat_opt_bubble_style": "말풍선 스타일", + "chat_opt_density": "메시지 밀도", + "chat_opt_emoji": "이모지 스타일", + "chat_opt_enter_sends": "Enter로 전송", + "chat_opt_font_size": "글자 크기", + "chat_opt_global_clock": "전역 시간 형식", + "chat_opt_poll": "폴링 주기", + "chat_opt_timestamp": "타임스탬프", "chat_pick_contact": "연락처에서 선택…", + "chat_rename": "연락처 이름 변경", + "chat_rename_hint": "연락처 이름", + "chat_renamed": "연락처 이름이 변경되었습니다", "chat_retry": "다시 시도", "chat_search": "대화 검색", + "chat_sec_appearance": "모양", + "chat_sec_messaging": "메시지", "chat_select_hint": "볼 대화를 선택하세요.", "chat_send": "전송", "chat_send_failed": "전송 안 됨", "chat_sending": "전송 중…", + "chat_settings_done": "완료", + "chat_settings_section": "채팅 및 연락처", + "chat_settings_tip": "채팅 사용자 지정", + "chat_settings_title": "채팅 설정", "chat_show_hidden": "숨김 보기", "chat_time_now": "방금", "chat_toast_compose_failed": "메시지를 작성할 수 없습니다 (너무 긴가요?).", @@ -181,10 +212,16 @@ "chat_toast_request_compose_failed": "연락 요청을 작성할 수 없습니다 (잘못된 주소 / 텍스트?).", "chat_toast_request_queued": "연락 요청이 대기열에 추가되었습니다.", "chat_toast_waiting_reply": "상대방이 답장해야 메시지를 보낼 수 있습니다.", + "chat_today": "오늘", + "chat_ts_12h": "12시간", + "chat_ts_24h": "24시간", + "chat_ts_global": "전역 설정 따르기", + "chat_ts_global_short": "전역", "chat_unhide": "다시 표시", "chat_unmute": "음소거 해제", "chat_verify_key": "신원 키 — 비교하여 확인", "chat_waiting_reply": "상대방의 답장을 기다리는 중입니다 — 답장하면 메시지를 보낼 수 있습니다.", + "chat_yesterday": "어제", "chat_you": "나", "choose_icon": "아이콘 선택", "clear": "지우기", @@ -196,6 +233,7 @@ "click_copy_address": "클릭하여 주소 복사", "click_copy_uri": "클릭하여 URI 복사", "click_to_copy": "복사하려면 클릭", + "clock_format": "시간 형식", "close": "닫기", "conf_count": "%d 확인", "confirm_and_send": "확인 후 전송", @@ -1388,6 +1426,7 @@ "tt_change_pass": "지갑 암호화 비밀번호 변경", "tt_change_pin": "잠금 해제 PIN 변경", "tt_clear_ztx": "로컬에 캐시된 z-트랜잭션 기록 삭제", + "tt_clock_format": "24시간 또는 12시간 형식(앱 전체). 채팅에서 재정의할 수 있습니다.", "tt_custom_fees": "거래 전송 시 수동 수수료 입력 활성화", "tt_custom_theme": "사용자 지정 테마 활성화됨", "tt_daemon_install_bundled": "노드를 중지하고 설치된 dragonxd를 이 지갑 빌드에 번들된 버전으로 덮어쓴 다음 재시작합니다", diff --git a/res/lang/pt.json b/res/lang/pt.json index 0f88701..0924cf2 100644 --- a/res/lang/pt.json +++ b/res/lang/pt.json @@ -135,12 +135,25 @@ "change_pass_title": "Alterar senha", "characters": "caracteres", "chat": "Chat", + "chat_accent_amber": "Âmbar", + "chat_accent_blue": "Azul", + "chat_accent_green": "Verde", + "chat_accent_pink": "Rosa", + "chat_accent_purple": "Roxo", + "chat_accent_theme": "Tema", "chat_add_contact": "Adicionar contato", "chat_awaiting_key": "Aguardando resposta", + "chat_bubble_minimal": "Mínimo", + "chat_bubble_rounded": "Arredondado", + "chat_bubble_square": "Quadrado", "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_density_comfortable": "Confortável", + "chat_density_compact": "Compacta", + "chat_emoji_color": "Colorido", + "chat_emoji_mono": "Monocromático", "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\".", @@ -156,21 +169,39 @@ "chat_len_over": "Mensagem muito longa", "chat_locked_hint": "Desbloqueie sua carteira para carregar suas conversas.", "chat_mute": "Silenciar", - "chat_new_button": "Nova conversa", + "chat_new_button": "Novo chat", "chat_new_message": "Mensagem", "chat_new_message_toast": "Nova mensagem de chat criptografada", "chat_new_send": "Enviar solicitação", - "chat_new_title": "Nova conversa", + "chat_new_title": "Novo chat", "chat_new_zaddr": "Endereço-z do destinatário", "chat_no_matches": "Nenhuma conversa corresponde à sua pesquisa.", "chat_no_z_contacts": "Ainda não há contatos com endereço blindado", + "chat_opt_bubble_accent": "Cor do balão", + "chat_opt_bubble_style": "Estilo do balão", + "chat_opt_density": "Densidade das mensagens", + "chat_opt_emoji": "Estilo de emoji", + "chat_opt_enter_sends": "Enter envia a mensagem", + "chat_opt_font_size": "Tamanho do texto", + "chat_opt_global_clock": "Formato de relógio global", + "chat_opt_poll": "Taxa de atualização", + "chat_opt_timestamp": "Carimbos de data/hora", "chat_pick_contact": "Escolher dos contatos…", + "chat_rename": "Renomear contato", + "chat_rename_hint": "Nome do contato", + "chat_renamed": "Contato renomeado", "chat_retry": "Tentar novamente", "chat_search": "Pesquisar conversas", + "chat_sec_appearance": "APARÊNCIA", + "chat_sec_messaging": "MENSAGENS", "chat_select_hint": "Selecione uma conversa para visualizá-la.", "chat_send": "Enviar", "chat_send_failed": "não enviada", "chat_sending": "enviando…", + "chat_settings_done": "Concluído", + "chat_settings_section": "CHAT E CONTATOS", + "chat_settings_tip": "Personalizar chat", + "chat_settings_title": "Configurações de chat", "chat_show_hidden": "Ver ocultas", "chat_time_now": "agora", "chat_toast_compose_failed": "Não foi possível compor a mensagem (muito longa?).", @@ -181,10 +212,16 @@ "chat_toast_request_compose_failed": "Não foi possível compor a solicitação de contato (endereço / texto inválido?).", "chat_toast_request_queued": "Solicitação de contato na fila.", "chat_toast_waiting_reply": "Aguardando a resposta do contato antes que você possa enviar mensagens a ele.", + "chat_today": "Hoje", + "chat_ts_12h": "12 horas", + "chat_ts_24h": "24 horas", + "chat_ts_global": "Seguir global", + "chat_ts_global_short": "Global", "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_yesterday": "Ontem", "chat_you": "Você", "choose_icon": "Escolher Ícone", "clear": "Limpar", @@ -196,6 +233,7 @@ "click_copy_address": "Clique para copiar o endereço", "click_copy_uri": "Clique para copiar a URI", "click_to_copy": "Clique para copiar", + "clock_format": "Formato de hora", "close": "Fechar", "conf_count": "%d conf.", "confirm_and_send": "Confirmar & Enviar", @@ -1388,6 +1426,7 @@ "tt_change_pass": "Alterar a frase secreta de encriptação da carteira", "tt_change_pin": "Alterar seu PIN de desbloqueio", "tt_clear_ztx": "Excluir histórico de z-transações em cache local", + "tt_clock_format": "Relógio de 24 ou 12 horas, em todo o app. O chat pode substituí-lo.", "tt_custom_fees": "Ativar entrada manual de taxas ao enviar transações", "tt_custom_theme": "Tema personalizado ativo", "tt_daemon_install_bundled": "Parar o nó, sobrescrever o dragonxd instalado com a versão incluída nesta compilação da carteira e reiniciar", diff --git a/res/lang/ru.json b/res/lang/ru.json index 3f13269..6e60fd9 100644 --- a/res/lang/ru.json +++ b/res/lang/ru.json @@ -135,12 +135,25 @@ "change_pass_title": "Сменить пароль", "characters": "символов", "chat": "Чат", + "chat_accent_amber": "Янтарный", + "chat_accent_blue": "Синий", + "chat_accent_green": "Зелёный", + "chat_accent_pink": "Розовый", + "chat_accent_purple": "Фиолетовый", + "chat_accent_theme": "Тема", "chat_add_contact": "Добавить контакт", "chat_awaiting_key": "Ожидание ответа", + "chat_bubble_minimal": "Минимальный", + "chat_bubble_rounded": "Скруглённый", + "chat_bubble_square": "Прямоугольный", "chat_cancel": "Отмена", "chat_contact_added": "Контакт добавлен — переименуйте его в Контактах", "chat_contact_request": "запрос контакта", "chat_copy_address_tip": "Нажмите, чтобы скопировать адрес", + "chat_density_comfortable": "Свободная", + "chat_density_compact": "Компактная", + "chat_emoji_color": "Цветной", + "chat_emoji_mono": "Монохромный", "chat_emoji_search": "Поиск эмодзи", "chat_empty_hint": "Пока нет переписок. Полученные сообщения появятся здесь.", "chat_empty_start": "Начните новый с помощью «Новый разговор».", @@ -156,21 +169,39 @@ "chat_len_over": "Сообщение слишком длинное", "chat_locked_hint": "Разблокируйте кошелёк, чтобы загрузить переписку.", "chat_mute": "Отключить уведомления", - "chat_new_button": "Новая переписка", + "chat_new_button": "Новый чат", "chat_new_message": "Сообщение", "chat_new_message_toast": "Новое зашифрованное сообщение", "chat_new_send": "Отправить запрос", - "chat_new_title": "Новая переписка", + "chat_new_title": "Новый чат", "chat_new_zaddr": "Z-адрес получателя", "chat_no_matches": "Нет разговоров, соответствующих запросу.", "chat_no_z_contacts": "Пока нет контактов с защищённым адресом", + "chat_opt_bubble_accent": "Цвет пузырька", + "chat_opt_bubble_style": "Стиль пузырька", + "chat_opt_density": "Плотность сообщений", + "chat_opt_emoji": "Стиль эмодзи", + "chat_opt_enter_sends": "Enter отправляет сообщение", + "chat_opt_font_size": "Размер текста", + "chat_opt_global_clock": "Глобальный формат времени", + "chat_opt_poll": "Частота опроса", + "chat_opt_timestamp": "Метки времени", "chat_pick_contact": "Выбрать из контактов…", + "chat_rename": "Переименовать контакт", + "chat_rename_hint": "Имя контакта", + "chat_renamed": "Контакт переименован", "chat_retry": "Повторить", "chat_search": "Поиск разговоров", + "chat_sec_appearance": "ВИД", + "chat_sec_messaging": "СООБЩЕНИЯ", "chat_select_hint": "Выберите переписку для просмотра.", "chat_send": "Отправить", "chat_send_failed": "не отправлено", "chat_sending": "отправка…", + "chat_settings_done": "Готово", + "chat_settings_section": "ЧАТ И КОНТАКТЫ", + "chat_settings_tip": "Настройка чата", + "chat_settings_title": "Настройки чата", "chat_show_hidden": "Показать скрытые", "chat_time_now": "сейчас", "chat_toast_compose_failed": "Не удалось составить сообщение (слишком длинное?).", @@ -181,10 +212,16 @@ "chat_toast_request_compose_failed": "Не удалось составить запрос контакта (неверный адрес / текст?).", "chat_toast_request_queued": "Запрос контакта поставлен в очередь.", "chat_toast_waiting_reply": "Ожидание ответа от контакта — вы сможете писать ему только после этого.", + "chat_today": "Сегодня", + "chat_ts_12h": "12 часов", + "chat_ts_24h": "24 часа", + "chat_ts_global": "Как глобально", + "chat_ts_global_short": "Общий", "chat_unhide": "Показать", "chat_unmute": "Включить уведомления", "chat_verify_key": "Ключ личности — сравните для проверки", "chat_waiting_reply": "Ожидание ответа от контакта — вы сможете писать ему, как только он ответит.", + "chat_yesterday": "Вчера", "chat_you": "Вы", "choose_icon": "Выбрать иконку", "clear": "Очистить", @@ -196,6 +233,7 @@ "click_copy_address": "Нажмите, чтобы скопировать адрес", "click_copy_uri": "Нажмите, чтобы скопировать URI", "click_to_copy": "Нажмите для копирования", + "clock_format": "Формат времени", "close": "Закрыть", "conf_count": "%d подтв.", "confirm_and_send": "Подтвердить и отправить", @@ -1388,6 +1426,7 @@ "tt_change_pass": "Сменить пароль шифрования кошелька", "tt_change_pin": "Изменить PIN-код разблокировки", "tt_clear_ztx": "Удалить локально кешированную историю z-транзакций", + "tt_clock_format": "24- или 12-часовой формат для всего приложения. Чат может переопределить.", "tt_custom_fees": "Включить ручной ввод комиссий при отправке транзакций", "tt_custom_theme": "Пользовательская тема активна", "tt_daemon_install_bundled": "Остановить узел, перезаписать установленный dragonxd версией, встроенной в эту сборку кошелька, затем перезапустить", diff --git a/res/lang/zh.json b/res/lang/zh.json index 26f9004..5990525 100644 --- a/res/lang/zh.json +++ b/res/lang/zh.json @@ -135,12 +135,25 @@ "change_pass_title": "更改密码短语", "characters": "字符", "chat": "聊天", + "chat_accent_amber": "琥珀色", + "chat_accent_blue": "蓝色", + "chat_accent_green": "绿色", + "chat_accent_pink": "粉色", + "chat_accent_purple": "紫色", + "chat_accent_theme": "主题", "chat_add_contact": "添加联系人", "chat_awaiting_key": "等待回复", + "chat_bubble_minimal": "极简", + "chat_bubble_rounded": "圆角", + "chat_bubble_square": "方形", "chat_cancel": "取消", "chat_contact_added": "已添加联系人——可在联系人中重命名", "chat_contact_request": "联系人请求", "chat_copy_address_tip": "点击复制地址", + "chat_density_comfortable": "宽松", + "chat_density_compact": "紧凑", + "chat_emoji_color": "彩色", + "chat_emoji_mono": "单色", "chat_emoji_search": "搜索表情", "chat_empty_hint": "暂无对话。您收到的消息将显示在此处。", "chat_empty_start": "点击\"新建会话\"开始。", @@ -156,21 +169,39 @@ "chat_len_over": "消息过长", "chat_locked_hint": "解锁钱包以加载您的聊天记录。", "chat_mute": "静音", - "chat_new_button": "新建对话", + "chat_new_button": "新聊天", "chat_new_message": "消息", "chat_new_message_toast": "新的加密聊天消息", "chat_new_send": "发送请求", - "chat_new_title": "新建对话", + "chat_new_title": "新聊天", "chat_new_zaddr": "收款方 z 地址", "chat_no_matches": "没有与搜索匹配的会话。", "chat_no_z_contacts": "暂无使用隐私地址的联系人", + "chat_opt_bubble_accent": "气泡颜色", + "chat_opt_bubble_style": "气泡样式", + "chat_opt_density": "消息密度", + "chat_opt_emoji": "表情样式", + "chat_opt_enter_sends": "回车发送消息", + "chat_opt_font_size": "文字大小", + "chat_opt_global_clock": "全局时间格式", + "chat_opt_poll": "轮询频率", + "chat_opt_timestamp": "时间戳", "chat_pick_contact": "从联系人中选择…", + "chat_rename": "重命名联系人", + "chat_rename_hint": "联系人名称", + "chat_renamed": "联系人已重命名", "chat_retry": "重试", "chat_search": "搜索会话", + "chat_sec_appearance": "外观", + "chat_sec_messaging": "消息", "chat_select_hint": "选择一个对话以查看。", "chat_send": "发送", "chat_send_failed": "未发送", "chat_sending": "发送中…", + "chat_settings_done": "完成", + "chat_settings_section": "聊天与联系人", + "chat_settings_tip": "聊天自定义", + "chat_settings_title": "聊天设置", "chat_show_hidden": "显示已隐藏", "chat_time_now": "刚刚", "chat_toast_compose_failed": "无法编写该消息(内容过长?)。", @@ -181,10 +212,16 @@ "chat_toast_request_compose_failed": "无法编写联系人请求(地址或文本无效?)。", "chat_toast_request_queued": "联系人请求已排队。", "chat_toast_waiting_reply": "等待该联系人回复——对方回复后您即可向其发送消息。", + "chat_today": "今天", + "chat_ts_12h": "12小时", + "chat_ts_24h": "24小时", + "chat_ts_global": "跟随全局", + "chat_ts_global_short": "全局", "chat_unhide": "取消隐藏", "chat_unmute": "取消静音", "chat_verify_key": "身份密钥 — 对比以验证", "chat_waiting_reply": "等待该联系人回复——对方回复后您即可向其发送消息。", + "chat_yesterday": "昨天", "chat_you": "我", "choose_icon": "选择图标", "clear": "清除", @@ -196,6 +233,7 @@ "click_copy_address": "点击复制地址", "click_copy_uri": "点击复制 URI", "click_to_copy": "点击复制", + "clock_format": "时间格式", "close": "关闭", "conf_count": "%d 确认", "confirm_and_send": "确认并发送", @@ -1388,6 +1426,7 @@ "tt_change_pass": "更改钱包加密密码", "tt_change_pin": "更改您的解锁 PIN", "tt_clear_ztx": "删除本地缓存的 z-交易历史", + "tt_clock_format": "24 或 12 小时制,应用全局。聊天可覆盖。", "tt_custom_fees": "发送交易时启用手动费用输入", "tt_custom_theme": "自定义主题已激活", "tt_daemon_install_bundled": "停止节点,用此钱包版本内置的 dragonxd 覆盖已安装的版本,然后重启", diff --git a/src/ui/pages/settings_page.cpp b/src/ui/pages/settings_page.cpp index e5cf22a..00f2a12 100644 --- a/src/ui/pages/settings_page.cpp +++ b/src/ui/pages/settings_page.cpp @@ -12,6 +12,7 @@ #include #include "../../util/logger.h" #include "../windows/balance_tab.h" +#include "../windows/chat_tab.h" // RenderChatSettingsControls (shared Chat & Contacts controls) #include "../windows/console_tab.h" #include "../../util/i18n.h" #include "../../util/platform.h" @@ -821,6 +822,25 @@ void RenderSettingsPage(App* app) { ImGui::Dummy(ImVec2(0, Layout::spacingSm())); + // --- Clock format (app-wide 24h / 12h — the Chat tab can override it in chat settings) --- + { + ImGui::PushFont(body2); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(TR("clock_format")); + ImGui::SameLine(0, Layout::spacingMd()); + int cf = app->settings() ? app->settings()->getTimeFormat() : 0; + const char* cfItems[] = { TR("chat_ts_24h"), TR("chat_ts_12h") }; + ImGui::SetNextItemWidth(160.0f * Layout::dpiScale()); + if (ImGui::Combo("##ClockFormat", &cf, cfItems, 2) && app->settings()) { + app->settings()->setTimeFormat(cf); + app->settings()->save(); + } + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_clock_format")); + ImGui::PopFont(); + } + + ImGui::Dummy(ImVec2(0, Layout::spacingSm())); + // --- Font Scale slider (always visible) --- { ImGui::PushFont(body2); @@ -1086,6 +1106,25 @@ void RenderSettingsPage(App* app) { ImGui::Dummy(ImVec2(0, Layout::spacingSm())); + // --- Clock format (app-wide 24h / 12h — the Chat tab can override it in chat settings) --- + { + ImGui::PushFont(body2); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(TR("clock_format")); + ImGui::SameLine(0, Layout::spacingMd()); + int cf = app->settings() ? app->settings()->getTimeFormat() : 0; + const char* cfItems[] = { TR("chat_ts_24h"), TR("chat_ts_12h") }; + ImGui::SetNextItemWidth(160.0f * Layout::dpiScale()); + if (ImGui::Combo("##ClockFormat", &cf, cfItems, 2) && app->settings()) { + app->settings()->setTimeFormat(cf); + app->settings()->save(); + } + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_clock_format")); + ImGui::PopFont(); + } + + ImGui::Dummy(ImVec2(0, Layout::spacingSm())); + // --- Font Scale slider (always visible) --- { ImGui::PushFont(body2); @@ -2445,6 +2484,21 @@ void RenderSettingsPage(App* app) { ImGui::Dummy(ImVec2(0, gap)); + // ==================================================================== + // CHAT & CONTACTS — card (same controls as the Chat tab's settings notch) + // ==================================================================== + { + Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("chat_settings_section")); + ImGui::Dummy(ImVec2(0, Layout::spacingXs())); + + material::GlassCardScope card(dl, availWidth, pad, bottomPad, glassSpec); + ImGui::PushFont(body2); + RenderChatSettingsControls(app, availWidth - pad * 2.0f); // card inner width (GlassCard doesn't narrow it) + ImGui::PopFont(); + } + + ImGui::Dummy(ImVec2(0, gap)); + // ==================================================================== // ABOUT — card // ==================================================================== diff --git a/src/ui/windows/chat_tab.cpp b/src/ui/windows/chat_tab.cpp index ae9711c..fea4973 100644 --- a/src/ui/windows/chat_tab.cpp +++ b/src/ui/windows/chat_tab.cpp @@ -52,6 +52,20 @@ bool s_show_hidden = false; // when on, the list also shows hidden conversatio bool s_show_emoji_picker = false; // emoji picker overlay — fills the conversation-list pane while open char s_emoji_search[48] = ""; // emoji picker keyword filter +// Inline contact rename (Tier C): the conversation whose header name is being edited (empty = none), +// the edit buffer, and a one-shot flag to grab keyboard focus the frame the field appears. +std::string s_rename_cid; +char s_rename_buf[128] = ""; // matches the Contacts tab's label editor capacity (no silent truncation) +bool s_rename_focus = false; + +// Chat customization modal (opened by the header settings "notch"). The same controls also render in +// Settings → Chat & Contacts via RenderChatSettingsControls(). +bool s_show_chat_settings = false; + +// Resolved once per frame from the chat/global timestamp settings — read by formatTime() (which has no +// App access). true => 12-hour clock in the Chat tab. +bool s_chat_time_12h = false; + // Effective draw-list font size for a material font (mirrors sidebar's ScaledFontSize). float scaledSize(ImFont* f) { return f->LegacySize * ImGui::GetStyle().FontScaleMain; } @@ -75,8 +89,42 @@ std::string formatTime(std::int64_t ts) { std::time_t t = static_cast(ts); std::tm* tm = std::localtime(&t); // UI thread only if (!tm) return ""; + char buf[40]; + std::strftime(buf, sizeof(buf), s_chat_time_12h ? "%Y-%m-%d %I:%M %p" : "%Y-%m-%d %H:%M", tm); + return buf; +} + +// Time-of-day only (no date), honoring the chat clock. Used on grouped meta lines + hover (the date +// now lives in a per-day separator, so the group header doesn't repeat it). UI thread only. +std::string chatTimeOnly(std::int64_t ts) { + if (ts <= 0) return ""; + std::time_t t = static_cast(ts); + std::tm* tm = std::localtime(&t); + if (!tm) return ""; + char buf[24]; + std::strftime(buf, sizeof(buf), s_chat_time_12h ? "%I:%M %p" : "%H:%M", tm); + return buf; +} + +// Local calendar-day key (YYYYMMDD) for detecting day boundaries between messages. UI thread only. +std::int64_t chatDayKey(std::int64_t ts) { + std::time_t t = static_cast(ts); + std::tm* tm = std::localtime(&t); + if (!tm) return -1; + return static_cast(tm->tm_year + 1900) * 10000 + + static_cast(tm->tm_mon + 1) * 100 + tm->tm_mday; +} + +// Date-separator label: "Today" / "Yesterday" / "Jul 16, 2026". UI thread only. +std::string chatDaySeparator(std::int64_t ts, std::int64_t todayKey, std::int64_t yestKey) { + const std::int64_t k = chatDayKey(ts); + if (k == todayKey) return TR("chat_today"); + if (k == yestKey) return TR("chat_yesterday"); + std::time_t t = static_cast(ts); + std::tm* tm = std::localtime(&t); + if (!tm) return ""; char buf[32]; - std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M", tm); + std::strftime(buf, sizeof(buf), "%b %d, %Y", tm); return buf; } @@ -117,6 +165,18 @@ ImU32 avatarColor(const std::string& seed) { return kPalette[h % (sizeof(kPalette) / sizeof(kPalette[0]))]; } +// Outgoing-bubble accent base color for a chat_bubble_accent preset (0 = theme primary). +ImU32 bubbleAccentColor(int accent) { + switch (accent) { + case 1: return IM_COL32(0x42, 0x85, 0xF4, 255); // blue + case 2: return IM_COL32(0x34, 0xA8, 0x53, 255); // green + case 3: return IM_COL32(0x9C, 0x27, 0xB0, 255); // purple + case 4: return IM_COL32(0xFF, 0xA7, 0x26, 255); // amber + case 5: return IM_COL32(0xEC, 0x40, 0x7A, 255); // pink + default: return material::Primary(); // theme + } +} + // Uppercase first glyph of a display name (UTF-8 aware) for a letter-avatar. std::string initialOf(const std::string& name) { std::size_t i = 0; @@ -313,6 +373,12 @@ void RenderChatTab(App* app) return; } + // Resolve the Chat-tab clock once per frame: the chat override wins, else the global format. + if (auto* cst = app->settings()) { + const int ctf = cst->getChatTimeFormat(); // 0=follow global, 1=24h, 2=12h + s_chat_time_12h = (ctf == 2) || (ctf == 0 && cst->getTimeFormat() == 1); + } + // Build conversation summaries (single scan per conversation), sorted by most-recent activity. std::vector convs; int hiddenCount = 0; @@ -354,6 +420,13 @@ void RenderChatTab(App* app) sodium_memzero(s_compose, sizeof(s_compose)); s_compose_cid = s_selected_cid; } + // Abandon an in-progress header rename tied to a different conversation (switch/hide), so a + // half-typed buffer can't silently reappear or land on the wrong contact (Tier C review). + if (!s_rename_cid.empty() && s_rename_cid != s_selected_cid) { + s_rename_cid.clear(); + s_rename_buf[0] = '\0'; + s_rename_focus = false; + } const ImVec2 avail = ImGui::GetContentRegionAvail(); const float listW = std::clamp(avail.x * 0.32f, 220.0f, 360.0f); @@ -371,39 +444,49 @@ void RenderChatTab(App* app) // ---- Left: new-conversation button + conversation list ---- // Faint sidebar tint so the list reads as distinct from the thread pane (V6). ImGui::PushStyleColor(ImGuiCol_ChildBg, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::OnSurface(), 10))); - ImGui::BeginChild("##ChatList", ImVec2(listW, avail.y), true); + ImGui::BeginChild("##ChatList", ImVec2(listW, avail.y), ImGuiChildFlags_Borders, + ImGuiWindowFlags_NoScrollWithMouse); + material::ApplySmoothScroll(); // wheel-driven lerp scroll, matching the rest of the app if (s_show_emoji_picker) { // Emoji picker overlay takes over the conversation-list pane while open (search + cancel + grid). renderEmojiPickerOverlay(s_compose, sizeof(s_compose)); } else { - // Accented "New conversation" action (house tactile style). + // "New chat" (accented) + "Show hidden (N)" toggle, side by side. Show-hidden only appears when + // something is hidden; otherwise New chat spans the row. + const float ldp = Layout::dpiScale(); + const bool hasHidden = hiddenCount > 0; ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Primary(), 205))); ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(material::Primary())); ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Primary(), 235))); - const bool newClicked = material::TactileButton(TR("chat_new_button"), ImVec2(-FLT_MIN, 0.0f)); - ImGui::PopStyleColor(3); + bool newClicked = false; + if (hasHidden) { + const std::string hlShow = std::string(TR("chat_show_hidden")) + " (" + std::to_string(hiddenCount) + ")"; + const std::string hlHide = TR("chat_hide_hidden"); + const std::string hl = s_show_hidden ? hlHide : hlShow; + // Size the toggle to the WIDER of its two labels so it (and New chat) don't resize on click. + const float hbw = std::max(ImGui::CalcTextSize(hlShow.c_str()).x, + ImGui::CalcTextSize(hlHide.c_str()).x) + 16.0f * ldp; + const float newW = std::max(80.0f * ldp, + ImGui::GetContentRegionAvail().x - hbw - ImGui::GetStyle().ItemSpacing.x); + newClicked = material::TactileButton(TR("chat_new_button"), ImVec2(newW, 0.0f)); + ImGui::PopStyleColor(3); + ImGui::SameLine(); + if (material::TactileButton(hl.c_str(), ImVec2(hbw, 0.0f))) s_show_hidden = !s_show_hidden; + } else { + s_show_hidden = false; // nothing hidden → keep the toggle off + newClicked = material::TactileButton(TR("chat_new_button"), ImVec2(-FLT_MIN, 0.0f)); + ImGui::PopStyleColor(3); + } if (newClicked) { s_show_new_convo = true; s_new_zaddr[0] = '\0'; s_new_msg[0] = '\0'; } - // Search filter (Q8) — only worth showing once there's more than one conversation. - if (convs.size() > 1) { + // Search filter (Q8) — always available (not gated by conversation count or Show hidden). + if (!convs.empty() || hasHidden) { ImGui::SetNextItemWidth(-FLT_MIN); ImGui::InputTextWithHint("##chatsearch", TR("chat_search"), s_search, sizeof(s_search)); } - // Show-hidden toggle — only when there are hidden conversations. Reveals them in the list (with - // an Unhide action in the thread header). - if (hiddenCount > 0) { - ImGui::PushFont(metaFont); - const std::string lbl = s_show_hidden - ? std::string(TR("chat_hide_hidden")) - : std::string(TR("chat_show_hidden")) + " (" + std::to_string(hiddenCount) + ")"; - if (ImGui::SmallButton(lbl.c_str())) s_show_hidden = !s_show_hidden; - ImGui::PopFont(); - } else { - s_show_hidden = false; // nothing hidden → keep the toggle off - } const std::string search = s_search; ImGui::Separator(); if (convs.empty()) { @@ -489,47 +572,117 @@ void RenderChatTab(App* app) 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); - { - const ConvSummary* sel = nullptr; - for (const auto& c : convs) if (c.cid == s_selected_cid) { sel = &c; break; } + // Resolve the selection up front so we can reserve room for the composer BELOW the bordered message + // box — the input sits outside/below the box now (no divider above it). + const ConvSummary* sel = nullptr; + for (const auto& c : convs) if (c.cid == s_selected_cid) { sel = &c; break; } + const float tdp = Layout::dpiScale(); + const float composerBoxH = ImGui::GetTextLineHeight() * 2.6f; // multi-line composer (Q6) + // Reserve a stable composer strip below the box whenever a conversation is open (thin counter row + + // the input row, which sits flush with the strip/window bottom). Same height for the waiting hint so + // the box doesn't jump when a reply arrives. + const float composerAreaH = sel ? (composerBoxH + metaSz + 12.0f * tdp) : 0.0f; + + ImGui::BeginChild("##ChatThread", ImVec2(0, avail.y - composerAreaH), true); + { if (sel) { app->markChatConversationSeen(sel->cid, sel->lastTs); // viewing the thread clears its unread (Q1) - // ── 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). + // ── Header (single row): avatar + name + key-lock + compact address (left), a right-aligned + // icon toolbar (right). The address rides the name line to keep the header to one row. 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(); + ImFont* subFont = material::Type().subtitle1(); + const float subSz = scaledSize(subFont); + const float nameH = subSz; // subtitle1 line height + 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 + + // Avatar (centered in the row). + const ImVec2 avC(hp.x + avR, hp.y + rowH * 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), + const ImVec2 isz = subFont->CalcTextSizeA(subSz, FLT_MAX, 0.0f, init.c_str()); + hdl->AddText(subFont, subSz, ImVec2(avC.x - isz.x * 0.5f, avC.y - isz.y * 0.5f), IM_COL32(255, 255, 255, 235), init.c_str()); } - 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 bool hasAddr = !sel->peerZaddr.empty(); + const int bookIdx = hasAddr ? book.findByAddress(sel->peerZaddr) : -1; + const bool known = bookIdx >= 0; + const bool muted = app->settings() && app->settings()->isChatMuted(sel->cid); + const bool renaming = (s_rename_cid == sel->cid); + + // The toolbar's left edge is known up front (from the button count). A rename (edit) icon is + // shown whenever there's an address to save the contact under; the settings "notch" gear is + // always the rightmost icon. + const int nBtns = 4 + (hasAddr ? 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(); + + // Compact address + lock (or waiting-chip) metrics, reserved to the right of the name. + const bool showLock = hasAddr && !sel->peerPubKey.empty(); + const bool showWait = hasAddr && sel->peerPubKey.empty(); + const std::string sa = hasAddr ? shorten(sel->peerZaddr, 10, 6) : std::string(); + ImFont* icoFont = material::Type().iconSmall(); + const float icoSz = scaledSize(icoFont); + const float addrTextW = hasAddr ? metaFont->CalcTextSizeA(metaSz, FLT_MAX, 0.0f, sa.c_str()).x : 0.0f; + const float lockW = showLock ? icoFont->CalcTextSizeA(icoSz, FLT_MAX, 0.0f, ICON_MD_LOCK).x + 5.0f * hdpi : 0.0f; + const char* wl = showWait ? TR("chat_awaiting_key") : ""; + const float waitTextW = showWait ? metaFont->CalcTextSizeA(metaSz, FLT_MAX, 0.0f, wl).x : 0.0f; + const float waitChipW = showWait ? waitTextW + 14.0f * hdpi + 8.0f * hdpi : 0.0f; // chip pad + leading gap + const float addrGroupW = lockW + addrTextW + waitChipW; + const float nameSpacing = 12.0f * hdpi; + const float nameCY = hp.y + (rowH - nameH) * 0.5f; + const float nameW = subFont->CalcTextSizeA(subSz, FLT_MAX, 0.0f, sel->peerName.c_str()).x; + const float nameAvail = std::max(40.0f * hdpi, + toolbarLeft - gap - textX - (hasAddr ? addrGroupW + nameSpacing : 0.0f)); + + // Name — or an inline rename field (Tier C) that takes the whole name+address span. + if (renaming) { + const float nameMaxW = std::max(60.0f * hdpi, toolbarLeft - gap - textX); + ImGui::SetCursorScreenPos(ImVec2(textX, nameCY)); + ImGui::PushFont(subFont); + ImGui::SetNextItemWidth(nameMaxW); + if (s_rename_focus) { ImGui::SetKeyboardFocusHere(); s_rename_focus = false; } + const bool commit = ImGui::InputTextWithHint("##renamepeer", TR("chat_rename_hint"), + s_rename_buf, sizeof(s_rename_buf), ImGuiInputTextFlags_EnterReturnsTrue); + const bool cancel = ImGui::IsItemDeactivated() && !commit; // Escape or click-away + ImGui::PopFont(); + if (commit) { + std::string nm = s_rename_buf; + while (!nm.empty() && std::isspace(static_cast(nm.front()))) nm.erase(nm.begin()); + while (!nm.empty() && std::isspace(static_cast(nm.back()))) nm.pop_back(); + if (!nm.empty() && hasAddr) { + if (bookIdx >= 0) { + if (nm != book.entries()[bookIdx].label) { // skip a no-op rewrite/save + data::AddressBookEntry e = book.entries()[bookIdx]; + e.label = nm; // keep address/notes/scope/avatar + book.updateEntry(static_cast(bookIdx), e); + book.save(); + Notifications::instance().success(TR("chat_renamed")); + } + } else if (book.addEntry(data::AddressBookEntry(nm, sel->peerZaddr))) { + book.save(); + Notifications::instance().success(TR("chat_contact_added")); + } else { + Notifications::instance().error(TR("address_book_exists")); + } + } + s_rename_cid.clear(); + } else if (cancel) { + s_rename_cid.clear(); + } + } else { + ImGui::PushClipRect(ImVec2(textX, hp.y), ImVec2(textX + nameAvail, hp.y + rowH), true); + hdl->AddText(subFont, subSz, ImVec2(textX, nameCY), material::OnSurface(), sel->peerName.c_str()); + ImGui::PopClipRect(); + } // 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). @@ -544,14 +697,22 @@ void RenderChatTab(App* app) 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) { + // Rename (edit) the contact inline in the header. For a known peer it updates the label; + // for an unknown one it creates the contact under the typed name (folds in the old + // "add contact" quick action, but lets you name them) (Q2 / Tier C). + if (hasAddr) { 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")); + material::IconButtonStyle a = base; + a.tooltip = known ? TR("chat_rename") : TR("chat_add_contact"); + if (known) { a.color = material::OnSurfaceMedium(); } + if (material::IconButton("##hdr_rename", + known ? ICON_MD_EDIT : ICON_MD_PERSON_ADD, ifont, ImVec2(ib, ib), a)) { + s_rename_cid = sel->cid; + const std::string cur = known ? book.entries()[bookIdx].label : std::string(); + const std::size_t n = std::min(cur.size(), sizeof(s_rename_buf) - 1); + std::memcpy(s_rename_buf, cur.data(), n); + s_rename_buf[n] = '\0'; + s_rename_focus = true; } bx += ib + gap; } @@ -614,74 +775,101 @@ void RenderChatTab(App* app) Notifications::instance().info(TR("chat_hidden_toast")); } } + bx += ib + gap; + } + // Settings "notch" — the rightmost icon, on a faint pill so it reads as a distinct + // corner tab. Opens the chat-customization modal. + { + ImGui::SetCursorScreenPos(ImVec2(bx, by)); + material::IconButtonStyle a = base; + a.tooltip = TR("chat_settings_tip"); + a.restBg = material::WithAlpha(material::OnSurface(), 22); // the notch backing + if (material::IconButton("##hdr_settings", ICON_MD_SETTINGS, ifont, ImVec2(ib, ib), a)) + s_show_chat_settings = true; } } - // 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()) + // Compact address + key-lock (or waiting chip) placed right after the (capped) name, on the + // same row. Hidden while renaming (the field takes the whole span). + if (hasAddr && !renaming) { + float ax = textX + std::min(nameW, nameAvail) + nameSpacing; + // Key-verify lock — we hold the peer's identity key; hover shows a comparable fingerprint. + if (showLock) { + const float ly = hp.y + (rowH - icoSz) * 0.5f; + hdl->AddText(icoFont, icoSz, ImVec2(ax, ly), + material::WithAlpha(material::Primary(), 220), ICON_MD_LOCK); + if (ImGui::IsMouseHoveringRect(ImVec2(ax, ly), ImVec2(ax + lockW, ly + icoSz))) material::Tooltip("%s\n%s", TR("chat_verify_key"), keyFingerprint(sel->peerPubKey).c_str()); - ImGui::SameLine(0.0f, 5.0f * hdpi); + ax += lockW; } - // 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), + // Click-to-copy compact address. + const float ay = hp.y + (rowH - metaSz) * 0.5f; + ImGui::SetCursorScreenPos(ImVec2(ax, ay)); + const bool addrClicked = ImGui::InvisibleButton("##hdr_copyaddr", ImVec2(std::max(1.0f, addrTextW), metaSz)); + const bool addrHov = ImGui::IsItemHovered(); + hdl->AddText(metaFont, metaSz, ImVec2(ax, ay), + 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")); } + ax += addrTextW; + // Waiting-for-reply chip when the peer's identity key isn't known yet. + if (showWait) { + const float lead = 8.0f * hdpi, px = 7.0f * hdpi, py = 2.0f * hdpi; + const ImVec2 cp(ax + lead, hp.y + (rowH - (metaSz + 2.0f * py)) * 0.5f); + const ImVec2 pmax(cp.x + waitTextW + 2.0f * px, cp.y + metaSz + 2.0f * py); + hdl->AddRectFilled(cp, pmax, material::WithAlpha(material::Primary(), 38), (metaSz + 2.0f * py) * 0.5f); + hdl->AddText(metaFont, metaSz, 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 } + // Advance the layout cursor below the single header row, then divide it from the messages. + ImGui::SetCursorScreenPos(ImVec2(hp.x, hp.y + rowH + 4.0f * hdpi)); ImGui::Separator(); const float dp = Layout::dpiScale(); - const float composerBoxH = ImGui::GetTextLineHeight() * 2.6f; // multi-line composer (Q6) - // 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); + // Inset the message list from the pane edges so bubbles don't hug the border/scrollbar (8px). + // Zero the VERTICAL item spacing: the message loop reserves its own gaps via Dummy() (tight + // within a run so the grouped/merged corners read right), so the theme's 6px would double them. + const float origSpacingX = ImGui::GetStyle().ItemSpacing.x; + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(8.0f * dp, 8.0f * dp)); + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(origSpacingX, 0.0f)); + ImGui::BeginChild("##ChatMessages", ImVec2(0, ImGui::GetContentRegionAvail().y), + ImGuiChildFlags_None, ImGuiWindowFlags_NoScrollWithMouse); + material::ApplySmoothScroll(); // smooth wheel scroll; syncs with the auto-scroll-to-bottom below bool atBottom = true; const ImVec2 msgWinMin = ImGui::GetWindowPos(); const ImVec2 msgWinSize = ImGui::GetWindowSize(); { ImDrawList* dl = ImGui::GetWindowDrawList(); - const float bpad = 9.0f * dp, bround = 9.0f * dp; + // Bubble style / accent / density / text-size from chat customization settings. + auto* cs = app->settings(); + const int bubbleStyle = cs ? cs->getChatBubbleStyle() : 0; // 0 rounded, 1 square, 2 minimal + const bool compact = cs && cs->getChatDensity() == 1; + const float fscale = cs ? cs->getChatFontScale() : 1.0f; + // Message bodies read at ~18px by default (bigger than the 15px body2 used in the list), + // then the Text-size slider (fscale) multiplies it. nameSz is body2's draw size, so + // nameSz / kFontBody2() is the DPI/font-scale factor; ×18 gives an 18px base. ImGui 1.92 + // rebakes the font at the requested size, so the larger text stays crisp. + const float bodySz = nameSz * (18.0f / std::max(1.0f, Layout::kFontBody2())) * fscale; + const float bpad = (compact ? 6.0f : 9.0f) * dp; + const float bround = (bubbleStyle == 1 ? 2.0f : 9.0f) * dp; // square vs rounded/minimal + const int outFill = (bubbleStyle == 2 ? 26 : 46); // minimal = fainter fill + const int inFill = (bubbleStyle == 2 ? 14 : 22); + const float groupGap = (compact ? 4.0f : 7.0f) * dp; + const float msgGap = (compact ? 2.0f : 3.0f) * dp; + const ImU32 accentBase = bubbleAccentColor(cs ? cs->getChatBubbleAccent() : 0); const auto messages = store.conversation(s_selected_cid); - std::int64_t prevMin = -1; int prevDir = -1; + // Grouping + per-day separators (Tier 1). Same-sender messages within kGroupWindow share + // one meta header and stack tightly; a date pill is drawn once per calendar day. + const std::int64_t nowTs = static_cast(std::time(nullptr)); + const std::int64_t todayKey = chatDayKey(nowTs); + const std::int64_t yestKey = chatDayKey(nowTs - 86400); + constexpr std::int64_t kGroupWindow = 300; // 5 minutes + const float avR = 11.0f * dp; // in-thread peer avatar radius + const float gutter = 2.0f * avR + 6.0f * dp; // incoming bubbles indent past the avatar + ImFont* icf = material::Type().iconSmall(); + const float icfSz = scaledSize(icf); + std::int64_t prevTs = -1; int prevDir = -1; std::int64_t prevDay = -1; for (std::size_t mi = 0; mi < messages.size(); ++mi) { const auto& m = messages[mi]; ImGui::PushID(static_cast(mi)); @@ -689,49 +877,112 @@ void RenderChatTab(App* app) const bool request = (m.kind == chat::ChatMessageKind::ContactRequest); const bool failed = outgoing && m.delivery == chat::ChatDelivery::Failed; const bool sending = outgoing && m.delivery == chat::ChatDelivery::Sending; - const std::int64_t minute = m.timestamp / 60; - const bool startGroup = (prevDir != static_cast(outgoing)) || (minute != prevMin); + const std::int64_t day = chatDayKey(m.timestamp); + const bool newDay = (day != prevDay); + const bool startGroup = newDay || (prevDir != static_cast(outgoing)) || + (prevTs < 0) || (m.timestamp - prevTs > kGroupWindow); + // Does this bubble end its run? (for grouped-corner shaping + the avatar placement) + bool lastInGroup = true; + if (mi + 1 < messages.size()) { + const auto& n = messages[mi + 1]; + const bool nOut = (n.direction == chat::ChatDirection::Outgoing); + lastInGroup = (chatDayKey(n.timestamp) != day) || (nOut != outgoing) || + (n.timestamp - m.timestamp > kGroupWindow); + } const float availW = ImGui::GetContentRegionAvail().x; const float maxBubbleW = std::max(140.0f * dp, availW * 0.72f); const float innerW = maxBubbleW - 2.0f * bpad; - // Grouped meta line (sender + time), once per sender/minute run, aligned to the sender's side. + // ── Date separator (once per calendar day): a centered pill. + if (newDay) { + if (mi > 0) ImGui::Dummy(ImVec2(0.0f, groupGap + 4.0f * dp)); + const std::string ds = chatDaySeparator(m.timestamp, todayKey, yestKey); + const ImVec2 dsz = metaFont->CalcTextSizeA(metaSz, FLT_MAX, 0.0f, ds.c_str()); + const float dpx = 9.0f * dp, dpy = 2.0f * dp; + const ImVec2 dcur = ImGui::GetCursorScreenPos(); + const float cxs = dcur.x + std::max(0.0f, (availW - dsz.x) * 0.5f); + dl->AddRectFilled(ImVec2(cxs - dpx, dcur.y), ImVec2(cxs + dsz.x + dpx, dcur.y + dsz.y + 2.0f * dpy), + material::WithAlpha(material::OnSurface(), 24), (dsz.y + 2.0f * dpy) * 0.5f); + dl->AddText(metaFont, metaSz, ImVec2(cxs, dcur.y + dpy), material::OnSurfaceMedium(), ds.c_str()); + ImGui::Dummy(ImVec2(availW, dsz.y + 2.0f * dpy + 4.0f * dp)); + } + + // ── Grouped meta line (sender + time-only), once per run, on the sender's side. if (startGroup) { + if (mi > 0 && !newDay) ImGui::Dummy(ImVec2(0.0f, groupGap)); std::string meta = outgoing ? std::string(TR("chat_you")) : sel->peerName; - const std::string when = formatTime(m.timestamp); + const std::string when = chatTimeOnly(m.timestamp); if (!when.empty()) meta += " " + when; if (request) meta += " [" + std::string(TR("chat_contact_request")) + "]"; const ImVec2 msz = metaFont->CalcTextSizeA(metaSz, FLT_MAX, 0.0f, meta.c_str()); const ImVec2 mp = ImGui::GetCursorScreenPos(); - dl->AddText(metaFont, metaSz, ImVec2(outgoing ? mp.x + availW - msz.x : mp.x, mp.y), + dl->AddText(metaFont, metaSz, + ImVec2(outgoing ? mp.x + availW - msz.x : mp.x + gutter, mp.y), material::OnSurfaceMedium(), meta.c_str()); ImGui::Dummy(ImVec2(availW, metaSz + 3.0f * dp)); } - // Direction-aligned rounded bubble. - const ImVec2 tsz = nameFont->CalcTextSizeA(nameSz, innerW, innerW, m.body.c_str()); + // ── Direction-aligned bubble (rounding/fill/accent from settings; grouped corners). + const ImVec2 tsz = nameFont->CalcTextSizeA(bodySz, innerW, innerW, m.body.c_str()); const float bw = std::min(maxBubbleW, tsz.x + 2.0f * bpad); const float bh = tsz.y + 2.0f * bpad; const ImVec2 cur = ImGui::GetCursorScreenPos(); - const float bx = outgoing ? (cur.x + availW - bw) : cur.x; + const float bx = outgoing ? (cur.x + availW - bw) : (cur.x + gutter); const ImVec2 bmin(bx, cur.y), bmax(bx + bw, cur.y + bh); const ImU32 bubCol = failed ? material::WithAlpha(material::Error(), 40) - : outgoing ? material::WithAlpha(material::Primary(), 46) - : material::WithAlpha(material::OnSurface(), 22); - dl->AddRectFilled(bmin, bmax, bubCol, bround); - dl->AddText(nameFont, nameSz, ImVec2(bx + bpad, cur.y + bpad), material::OnSurface(), + : outgoing ? material::WithAlpha(accentBase, outFill) + : material::WithAlpha(material::OnSurface(), inFill); + // Flatten the corner between stacked bubbles from the same sender (iMessage-style). + ImDrawFlags rf = ImDrawFlags_RoundCornersAll; + if (bubbleStyle != 1) { // shape only rounded/minimal; square keeps its uniform corners + if (outgoing) { + if (!startGroup) rf &= ~ImDrawFlags_RoundCornersTopRight; + if (!lastInGroup) rf &= ~ImDrawFlags_RoundCornersBottomRight; + } else { + if (!startGroup) rf &= ~ImDrawFlags_RoundCornersTopLeft; + if (!lastInGroup) rf &= ~ImDrawFlags_RoundCornersBottomLeft; + } + } + dl->AddRectFilled(bmin, bmax, bubCol, bround, rf); + dl->AddText(nameFont, bodySz, ImVec2(bx + bpad, cur.y + bpad), material::OnSurface(), m.body.c_str(), nullptr, innerW); + + // Peer avatar beside the LAST incoming bubble of a run (Tier 3). + if (!outgoing && lastInGroup) { + const ImVec2 avc(cur.x + avR, cur.y + bh - avR); + dl->AddCircleFilled(avc, avR, avatarColor(sel->cid), 20); + const std::string init = initialOf(sel->peerName); + const ImVec2 isz = metaFont->CalcTextSizeA(metaSz, FLT_MAX, 0.0f, init.c_str()); + dl->AddText(metaFont, metaSz, ImVec2(avc.x - isz.x * 0.5f, avc.y - isz.y * 0.5f), + IM_COL32(255, 255, 255, 235), init.c_str()); + } ImGui::Dummy(ImVec2(availW, bh)); + + // Hover: reveal this message's own time at the outer bubble edge + right-click copy. if (ImGui::IsMouseHoveringRect(bmin, bmax)) { - material::Tooltip("%s", formatTime(m.timestamp).c_str()); + const std::string ht = chatTimeOnly(m.timestamp); + const ImVec2 hsz = metaFont->CalcTextSizeA(metaSz, FLT_MAX, 0.0f, ht.c_str()); + const float hx = outgoing ? (bmin.x - hsz.x - 6.0f * dp) : (bmax.x + 6.0f * dp); + dl->AddText(metaFont, metaSz, ImVec2(hx, bmin.y + (bh - hsz.y) * 0.5f), + material::WithAlpha(material::OnSurface(), 120), ht.c_str()); if (ImGui::IsMouseClicked(ImGuiMouseButton_Right)) ImGui::OpenPopup("##msgmenu"); } if (ImGui::BeginPopup("##msgmenu")) { if (ImGui::MenuItem(TR("copy"))) ImGui::SetClipboardText(m.body.c_str()); ImGui::EndPopup(); } - // Failed send → right-aligned "not sent" + Retry (Q7). + + // Delivery status for your messages: clock while sending, check once broadcast (Tier 2). + if (outgoing && !failed && (sending || lastInGroup)) { + const char* glyph = sending ? ICON_MD_SCHEDULE : ICON_MD_DONE; + const float gs = icfSz * 0.82f; + const ImVec2 gsz = icf->CalcTextSizeA(gs, FLT_MAX, 0.0f, glyph); + dl->AddText(icf, gs, ImVec2(bmax.x - gsz.x, bmax.y + 1.0f * dp), + material::WithAlpha(material::OnSurface(), sending ? 95 : 135), glyph); + ImGui::Dummy(ImVec2(0.0f, gs + 2.0f * dp)); // reserve the status glyph (no ItemSpacing now) + } + // Failed send → right-aligned "not sent" + Retry (kept — it's actionable). if (failed) { ImGui::Dummy(ImVec2(0.0f, 1.0f * dp)); const float ftw = metaFont->CalcTextSizeA(metaSz, FLT_MAX, 0.0f, TR("chat_send_failed")).x; @@ -750,18 +1001,9 @@ void RenderChatTab(App* app) s_scroll_to_cid = sel->cid; } } - // In-flight send → subtle right-aligned "sending…"; the op callback resolves it. - if (sending) { - ImGui::Dummy(ImVec2(0.0f, 1.0f * dp)); - const float sw = metaFont->CalcTextSizeA(metaSz, FLT_MAX, 0.0f, TR("chat_sending")).x; - ImGui::SetCursorPosX(ImGui::GetCursorPosX() + std::max(0.0f, availW - sw)); - ImGui::PushFont(metaFont); ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium()); - ImGui::TextUnformatted(TR("chat_sending")); - ImGui::PopStyleColor(); ImGui::PopFont(); - } - ImGui::Dummy(ImVec2(0.0f, 5.0f * dp)); + ImGui::Dummy(ImVec2(0.0f, lastInGroup ? msgGap : msgGap * 0.5f)); // tight within a run ImGui::PopID(); - prevMin = minute; prevDir = static_cast(outgoing); + prevTs = m.timestamp; prevDir = static_cast(outgoing); prevDay = day; } if (s_scroll_to_cid == s_selected_cid) { ImGui::SetScrollHereY(1.0f); @@ -770,6 +1012,7 @@ void RenderChatTab(App* app) atBottom = ImGui::GetScrollY() >= ImGui::GetScrollMaxY() - 4.0f; } ImGui::EndChild(); + ImGui::PopStyleVar(2); // ##ChatMessages WindowPadding + zeroed vertical ItemSpacing // Jump-to-latest pill when scrolled up (Q9). SetCursorScreenPos moves the parent cursor to // the child's bottom edge; save + restore it so the composer footer below stays anchored // (otherwise it renders ~8px too high only while the pill is shown). @@ -783,71 +1026,7 @@ void RenderChatTab(App* app) ImGui::PopStyleColor(2); ImGui::SetCursorScreenPos(savedCursor); } - - // Composer footer: message input + send (only once we know the peer's key), else a hint. - ImGui::Separator(); - if (sel->peerPubKey.empty()) { - ImGui::PushFont(metaFont); - ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium()); - ImGui::PushTextWrapPos(0.0f); - ImGui::TextUnformatted(TR("chat_waiting_reply")); - ImGui::PopTextWrapPos(); - ImGui::PopStyleColor(); - ImGui::PopFont(); - } else { - // Multi-line composer (Q6): Enter sends, Ctrl+Enter inserts a newline. A live byte counter - // tracks the effective on-chain body cap = (512 − len("utf8:"))/2 − secretstream ABYTES. - static constexpr int kBodyMaxBytes = (512 - 5) / 2 - 17; // = 236 (see chat_outgoing.cpp) - const float sendW = 80.0f * Layout::dpiScale(); // scaled so the translated label never clips (B3) - const float inputW = ImGui::GetContentRegionAvail().x - sendW - ImGui::GetStyle().ItemSpacing.x; - const int used = static_cast(std::strlen(s_compose)); - const bool overCap = used > kBodyMaxBytes; - bool submit = ImGui::InputTextMultiline("##compose", s_compose, sizeof(s_compose), - ImVec2(inputW, composerBoxH), - ImGuiInputTextFlags_EnterReturnsTrue | ImGuiInputTextFlags_CtrlEnterForNewLine); - ImGui::SameLine(); - ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Primary(), 205))); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(material::Primary())); - ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Primary(), 235))); - ImGui::BeginDisabled(overCap || s_compose[0] == '\0'); - if (material::TactileButton(TR("chat_send"), ImVec2(sendW, composerBoxH))) submit = true; - 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. 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 — 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; - ImGui::SetCursorPosX(ImGui::GetCursorPosX() + - std::max(0.0f, ImGui::GetContentRegionAvail().x - cw)); - ImGui::TextUnformatted(counter.c_str()); - ImGui::PopStyleColor(); - ImGui::PopFont(); - if (submit && s_compose[0] != '\0' && !overCap) { - app->sendChatMessage(sel->cid, s_compose); - sodium_memzero(s_compose, sizeof(s_compose)); - s_scroll_to_cid = sel->cid; - } - } + // Composer is rendered OUTSIDE this box, directly below it (see after EndChild). } else { if (convs.empty()) centeredEmptyState(ICON_MD_FORUM, TR("chat_empty_title"), TR("chat_empty_start")); else centeredEmptyState(ICON_MD_CHAT_BUBBLE_OUTLINE, TR("chat_select_hint"), nullptr); @@ -855,6 +1034,92 @@ void RenderChatTab(App* app) } ImGui::EndChild(); + // ── Composer — OUTSIDE / below the bordered message box, spanning its width. No divider above it. + // Layout (all placed by absolute screen pos so nothing wraps to the window's left edge): a thin + // byte-counter row just under the box, then the input row flush with the strip/window bottom, with + // the emoji toggle on the LEFT of the input and Send on the right. + if (sel) { + const ImVec2 boxMin = ImGui::GetItemRectMin(); // the ##ChatThread child's rect + const ImVec2 boxMax = ImGui::GetItemRectMax(); + const float cx = boxMin.x; + const float cw = boxMax.x - boxMin.x; + if (sel->peerPubKey.empty()) { + ImGui::SetCursorScreenPos(ImVec2(cx, boxMax.y + 8.0f * tdp)); + ImGui::PushFont(metaFont); + ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium()); + ImGui::PushTextWrapPos(0.0f); + ImGui::TextUnformatted(TR("chat_waiting_reply")); + ImGui::PopTextWrapPos(); + ImGui::PopStyleColor(); + ImGui::PopFont(); + } else { + // Multi-line composer (Q6). Enter behavior is setting-dependent (see below). A live byte + // counter tracks the on-chain body cap = (512 − len("utf8:"))/2 − secretstream ABYTES. + static constexpr int kBodyMaxBytes = (512 - 5) / 2 - 17; // = 236 (see chat_outgoing.cpp) + const float sendW = 80.0f * tdp; // scaled so the translated label never clips (B3) + const float emojiBtn = 28.0f * tdp; + const float inGap = 6.0f * tdp; + const int used = static_cast(std::strlen(s_compose)); + const bool overCap = used > kBodyMaxBytes; + + // Thin counter row (right-aligned) just under the box; over-cap tints + prefixes a label. + std::string counterLine = std::to_string(used) + " / " + std::to_string(kBodyMaxBytes); + if (overCap) counterLine = std::string(TR("chat_len_over")) + " " + counterLine; + const float cwid = metaFont->CalcTextSizeA(metaSz, FLT_MAX, 0.0f, counterLine.c_str()).x; + ImGui::GetWindowDrawList()->AddText(metaFont, metaSz, + ImVec2(cx + cw - cwid, boxMax.y + 3.0f * tdp), + overCap ? material::Error() : material::OnSurfaceMedium(), counterLine.c_str()); + + // Input row, flush with the strip/window bottom: [emoji] [input] [Send]. + const float rowY = boxMax.y + composerAreaH - composerBoxH; + const float inputX = cx + emojiBtn + inGap; + const float inputW = std::max(60.0f * tdp, cw - emojiBtn - 2.0f * inGap - sendW); + const float sendX = inputX + inputW + inGap; + + // Emoji toggle on the left, vertically centered in the input row. + ImGui::SetCursorScreenPos(ImVec2(cx, rowY + (composerBoxH - emojiBtn) * 0.5f)); + { + material::IconButtonStyle es; + es.hoverBg = material::WithAlpha(material::OnSurface(), 30); + es.bgRounding = 8.0f * tdp; + 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'; + } + } + // Input. + ImGui::SetCursorScreenPos(ImVec2(inputX, rowY)); + // Enter-to-send (setting): on → Enter sends + Ctrl+Enter newline; off → Enter is a newline and + // the Send button is the only way to send. NOTE: without EnterReturnsTrue, InputTextMultiline + // returns true on EVERY edit (value-changed), so in off-mode the return value must NOT drive + // submit — otherwise typing one character would fire a send. + const bool enterSends = !app->settings() || app->settings()->getChatEnterSends(); + const ImGuiInputTextFlags composeFlags = enterSends + ? (ImGuiInputTextFlags_EnterReturnsTrue | ImGuiInputTextFlags_CtrlEnterForNewLine) + : ImGuiInputTextFlags_None; + const bool inputReturned = ImGui::InputTextMultiline("##compose", s_compose, sizeof(s_compose), + ImVec2(inputW, composerBoxH), composeFlags); + bool submit = enterSends && inputReturned; // off-mode sends only via the Send button + // Send. + ImGui::SetCursorScreenPos(ImVec2(sendX, rowY)); + ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Primary(), 205))); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(material::Primary())); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Primary(), 235))); + ImGui::BeginDisabled(overCap || s_compose[0] == '\0'); + if (material::TactileButton(TR("chat_send"), ImVec2(sendW, composerBoxH))) submit = true; + ImGui::EndDisabled(); + ImGui::PopStyleColor(3); + + if (submit && s_compose[0] != '\0' && !overCap) { + app->sendChatMessage(sel->cid, s_compose); + sodium_memzero(s_compose, sizeof(s_compose)); + s_scroll_to_cid = sel->cid; + } + } + } + // ---- New-conversation dialog (send a contact request to a z-address) — house BlurFloat overlay ---- if (s_show_new_convo) { const float dp = Layout::dpiScale(); @@ -923,6 +1188,26 @@ void RenderChatTab(App* app) material::EndOverlayDialog(); } } + + // ---- Chat customization modal (opened by the header settings "notch") — house BlurFloat overlay ---- + if (s_show_chat_settings) { + material::OverlayDialogSpec ov; + ov.title = TR("chat_settings_title"); + ov.p_open = &s_show_chat_settings; // X / backdrop closes it + ov.style = material::OverlayStyle::BlurFloat; + ov.cardWidth = 520.0f; ov.idSuffix = "chatsettings"; + if (material::BeginOverlayDialog(ov)) { + RenderChatSettingsControls(app); + ImGui::Dummy(ImVec2(0.0f, 8.0f * Layout::dpiScale())); + ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Primary(), 205))); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(material::Primary())); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Primary(), 235))); + if (material::TactileButton(TR("chat_settings_done"), ImVec2(ImGui::GetContentRegionAvail().x, 0))) + s_show_chat_settings = false; + ImGui::PopStyleColor(3); + material::EndOverlayDialog(); + } + } } void ResetChatTab() @@ -932,10 +1217,155 @@ void ResetChatTab() sodium_memzero(s_compose, sizeof(s_compose)); sodium_memzero(s_new_zaddr, sizeof(s_new_zaddr)); sodium_memzero(s_new_msg, sizeof(s_new_msg)); + sodium_memzero(s_rename_buf, sizeof(s_rename_buf)); s_selected_cid.clear(); s_scroll_to_cid.clear(); s_compose_cid.clear(); + s_rename_cid.clear(); + s_rename_focus = false; s_show_new_convo = false; + s_show_chat_settings = false; +} + +void RenderChatSettingsControls(App* app, float contentWidth) +{ + auto* st = app ? app->settings() : nullptr; + if (!st) return; + const float dp = Layout::dpiScale(); + const float ctrlW = 250.0f * dp; // control column width (fits a 3-segment control comfortably) + const float rowGap = 5.0f * dp; + // Right-align controls to the row's true right edge. The Settings tab renders us inside a GlassCard + // whose content region isn't narrowed to the card padding, so it passes an explicit contentWidth; + // the chat modal's dialog content region is correct, so it passes 0 (auto). + const float leftX = ImGui::GetCursorPosX(); + const float rowW = (contentWidth > 0.0f) ? contentWidth : ImGui::GetContentRegionAvail().x; + + // Label left, control right-aligned within [leftX, leftX+rowW]. Leaves the cursor at the control origin. + auto beginRow = [&](const char* label) { + ImGui::Dummy(ImVec2(0.0f, rowGap)); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(label); + ImGui::SameLine(); + ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), leftX + std::max(0.0f, rowW - ctrlW))); + ImGui::SetNextItemWidth(ctrlW); + }; + auto section = [&](const char* key) { + ImGui::Dummy(ImVec2(0.0f, 8.0f * dp)); + ImGui::PushFont(material::Type().caption()); + ImGui::PushStyleColor(ImGuiCol_Text, material::WithAlpha(material::Primary(), 235)); + ImGui::TextUnformatted(TR(key)); + ImGui::PopStyleColor(); + ImGui::PopFont(); + ImGui::Dummy(ImVec2(0.0f, 2.0f * dp)); + }; + // Segmented control (iOS-style): a rounded track with an inset pill on the selected segment. + // Returns the (possibly changed) index. Reads the label via beginRow for a right-aligned control. + auto segmented = [&](const char* label, const char* const* items, int count, int value) -> int { + beginRow(label); + const ImVec2 origin = ImGui::GetCursorScreenPos(); + const float h = ImGui::GetFrameHeight(); + const float seg = ctrlW / static_cast(count); + const float round = 7.0f * dp; + ImDrawList* dl = ImGui::GetWindowDrawList(); + dl->AddRectFilled(origin, ImVec2(origin.x + ctrlW, origin.y + h), + material::WithAlpha(material::OnSurface(), 20), round); + int result = value; + ImGui::PushID(label); + for (int i = 0; i < count; ++i) { + ImGui::PushID(i); + const ImVec2 mn(origin.x + i * seg, origin.y), mx(origin.x + (i + 1) * seg, origin.y + h); + ImGui::SetCursorScreenPos(mn); + if (ImGui::InvisibleButton("##s", ImVec2(seg, h))) result = i; + const bool hov = ImGui::IsItemHovered(); + if (hov) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + const bool sel = (value == i); + if (sel) { + const float in = 2.0f * dp; + dl->AddRectFilled(ImVec2(mn.x + in, mn.y + in), ImVec2(mx.x - in, mx.y - in), + material::WithAlpha(material::Primary(), 210), std::max(1.0f, round - in)); + } else if (hov) { + dl->AddRectFilled(mn, mx, material::WithAlpha(material::OnSurface(), 26), round); + } + const ImVec2 ts = ImGui::CalcTextSize(items[i]); + // Center when the label fits; otherwise left-align with a small pad so the START stays + // readable (a long translation clipped on the right, not clipped on BOTH sides). + const float lpad = 4.0f * dp; + const float tx = (ts.x <= seg - 2.0f * lpad) ? (seg - ts.x) * 0.5f : lpad; + ImGui::PushClipRect(mn, mx, true); + dl->AddText(ImVec2(mn.x + tx, mn.y + (h - ts.y) * 0.5f), + sel ? IM_COL32(255, 255, 255, 236) : material::OnSurfaceMedium(), items[i]); + ImGui::PopClipRect(); + ImGui::PopID(); + } + ImGui::PopID(); + ImGui::SetCursorScreenPos(origin); + ImGui::Dummy(ImVec2(ctrlW, h)); // reserve the control's rect for layout flow + return result; + }; + + // ── Appearance ──────────────────────────────────────────────────────────────── + section("chat_sec_appearance"); + // Emoji style (monochrome / color). Color needs a FreeType build (native + the cross-built Windows + // FreeType); requestFontRebuild swaps the atlas live. + { + int v = st->getChatEmojiColor() ? 1 : 0; + const char* items[] = { TR("chat_emoji_mono"), TR("chat_emoji_color") }; + int nv = segmented(TR("chat_opt_emoji"), items, 2, v); + if (nv != v) { st->setChatEmojiColor(nv == 1); st->save(); app->requestFontRebuild(); } + } + // Bubble style (segmented) + accent color (a 6-way dropdown — too many for a segmented control). + { + const char* items[] = { TR("chat_bubble_rounded"), TR("chat_bubble_square"), TR("chat_bubble_minimal") }; + int v = st->getChatBubbleStyle(); + int nv = segmented(TR("chat_opt_bubble_style"), items, 3, v); + if (nv != v) { st->setChatBubbleStyle(nv); st->save(); } + } + { + beginRow(TR("chat_opt_bubble_accent")); + int v = st->getChatBubbleAccent(); + const char* items[] = { TR("chat_accent_theme"), TR("chat_accent_blue"), TR("chat_accent_green"), + TR("chat_accent_purple"), TR("chat_accent_amber"), TR("chat_accent_pink") }; + if (ImGui::Combo("##chat_baccent", &v, items, 6)) { st->setChatBubbleAccent(v); st->save(); } + } + // Message density (segmented). + { + const char* items[] = { TR("chat_density_comfortable"), TR("chat_density_compact") }; + int v = st->getChatDensity(); + int nv = segmented(TR("chat_opt_density"), items, 2, v); + if (nv != v) { st->setChatDensity(nv); st->save(); } + } + // Message text size (slider). + { + beginRow(TR("chat_opt_font_size")); + float v = st->getChatFontScale(); + if (ImGui::SliderFloat("##chat_font", &v, 0.8f, 1.5f, "%.2fx", ImGuiSliderFlags_AlwaysClamp)) { + st->setChatFontScale(v); st->save(); + } + } + + // ── Messaging ───────────────────────────────────────────────────────────────── + section("chat_sec_messaging"); + // Message poll rate (full-node 0-conf fast-scan cadence) — slider. + { + beginRow(TR("chat_opt_poll")); + float v = st->getChatPollRateSec(); + if (ImGui::SliderFloat("##chat_poll", &v, 0.5f, 15.0f, "%.1f s", ImGuiSliderFlags_AlwaysClamp)) { + st->setChatPollRateSec(v); st->save(); + } + } + // Chat timestamps (segmented) — overrides the app-wide clock (Settings → General) for this tab only. + { + const char* items[] = { TR("chat_ts_global_short"), TR("chat_ts_24h"), TR("chat_ts_12h") }; + int v = st->getChatTimeFormat(); + int nv = segmented(TR("chat_opt_timestamp"), items, 3, v); + if (nv != v) { st->setChatTimeFormat(nv); st->save(); } + } + // Enter-to-send (checkbox). + { + ImGui::Dummy(ImVec2(0.0f, rowGap)); + bool v = st->getChatEnterSends(); + if (ImGui::Checkbox(TR("chat_opt_enter_sends"), &v)) { st->setChatEnterSends(v); st->save(); } + } } } // namespace ui diff --git a/src/ui/windows/chat_tab.h b/src/ui/windows/chat_tab.h index 687f050..0e673ee 100644 --- a/src/ui/windows/chat_tab.h +++ b/src/ui/windows/chat_tab.h @@ -24,6 +24,18 @@ namespace ui { */ void RenderChatTab(App* app); +/** + * @brief Render the chat-customization controls (emoji style, poll rate, bubble style/color, + * density, text size, chat timestamps, Enter-to-send). Shared by the Chat tab's settings + * "notch" modal and the Settings tab's Chat & Contacts section. The app-wide clock format + * (which chat timestamps fall back to) lives separately in Settings → General. + * @param app Pointer to the app instance. + * @param contentWidth Explicit row width for right-aligning controls; pass the card's inner content + * width from the Settings tab (whose GlassCard doesn't narrow the content region). 0 = auto + * (use the current content region, correct inside the chat modal's dialog). + */ +void RenderChatSettingsControls(App* app, float contentWidth = 0.0f); + /** * @brief Securely wipe the Chat tab's UI-local state (composer / new-conversation * plaintext buffers + the selected-conversation ids). Called by diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index 6a19978..0e63c0d 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -217,11 +217,11 @@ void I18n::loadBuiltinEnglish() strings_["chat_you"] = "You"; strings_["chat_contact_request"] = "contact request"; strings_["chat_send_failed"] = "not sent"; - strings_["chat_new_button"] = "New conversation"; + strings_["chat_new_button"] = "New chat"; strings_["chat_select_hint"] = "Select a conversation to view it."; strings_["chat_waiting_reply"] = "Waiting for this contact to reply — you can message them once they do."; strings_["chat_send"] = "Send"; - strings_["chat_new_title"] = "New conversation"; + strings_["chat_new_title"] = "New chat"; strings_["chat_new_zaddr"] = "Recipient z-address"; strings_["chat_new_message"] = "Message"; strings_["chat_new_send"] = "Send request"; @@ -263,6 +263,44 @@ void I18n::loadBuiltinEnglish() 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"; + strings_["chat_rename"] = "Rename contact"; + strings_["chat_rename_hint"] = "Contact name"; + strings_["chat_renamed"] = "Contact renamed"; + // Chat customization (gear modal + Settings → Chat & Contacts) + strings_["chat_settings_title"] = "Chat settings"; + strings_["chat_settings_tip"] = "Chat customization"; + strings_["chat_settings_section"] = "CHAT & CONTACTS"; + strings_["chat_opt_emoji"] = "Emoji style"; + strings_["chat_emoji_mono"] = "Monochrome"; + strings_["chat_emoji_color"] = "Color"; + strings_["chat_opt_poll"] = "Message poll rate"; + strings_["chat_opt_bubble_style"] = "Bubble style"; + strings_["chat_bubble_rounded"] = "Rounded"; + strings_["chat_bubble_square"] = "Square"; + strings_["chat_bubble_minimal"] = "Minimal"; + strings_["chat_opt_bubble_accent"] = "Bubble color"; + strings_["chat_accent_theme"] = "Theme"; + strings_["chat_accent_blue"] = "Blue"; + strings_["chat_accent_green"] = "Green"; + strings_["chat_accent_purple"] = "Purple"; + strings_["chat_accent_amber"] = "Amber"; + strings_["chat_accent_pink"] = "Pink"; + strings_["chat_opt_density"] = "Message density"; + strings_["chat_density_comfortable"] = "Comfortable"; + strings_["chat_density_compact"] = "Compact"; + strings_["chat_opt_font_size"] = "Text size"; + strings_["chat_opt_timestamp"] = "Timestamps"; + strings_["chat_ts_global"] = "Follow global"; + strings_["chat_ts_24h"] = "24-hour"; + strings_["chat_ts_12h"] = "12-hour"; + strings_["chat_opt_enter_sends"] = "Enter sends message"; + strings_["chat_opt_global_clock"] = "Global clock format"; + strings_["chat_settings_done"] = "Done"; + strings_["chat_ts_global_short"] = "Global"; + strings_["chat_sec_appearance"] = "APPEARANCE"; + strings_["chat_sec_messaging"] = "MESSAGING"; + strings_["chat_today"] = "Today"; + strings_["chat_yesterday"] = "Yesterday"; // 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"; @@ -1148,6 +1186,8 @@ void I18n::loadBuiltinEnglish() strings_["network"] = "Network"; strings_["theme"] = "Theme"; strings_["language"] = "Language"; + strings_["clock_format"] = "Clock format"; + strings_["tt_clock_format"] = "24-hour or 12-hour clock, used across the app. The Chat tab can override it in its own settings."; strings_["dragonx_green"] = "DragonX (Green)"; strings_["dark"] = "Dark"; strings_["light"] = "Light";