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) <noreply@anthropic.com>
This commit is contained in:
2026-07-17 18:45:19 -05:00
parent 3b5db02e09
commit 2c909d35ea
13 changed files with 1060 additions and 212 deletions

Binary file not shown.

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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 をこのウォレットビルドにバンドルされたバージョンで上書きしてから再起動します",

View File

@@ -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를 이 지갑 빌드에 번들된 버전으로 덮어쓴 다음 재시작합니다",

View File

@@ -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",

View File

@@ -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 версией, встроенной в эту сборку кошелька, затем перезапустить",

View File

@@ -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 覆盖已安装的版本,然后重启",

View File

@@ -12,6 +12,7 @@
#include <sodium.h>
#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
// ====================================================================

View File

@@ -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<std::time_t>(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<std::time_t>(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<std::time_t>(ts);
std::tm* tm = std::localtime(&t);
if (!tm) return -1;
return static_cast<std::int64_t>(tm->tm_year + 1900) * 10000 +
static_cast<std::int64_t>(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<std::time_t>(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<ConvSummary> 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<unsigned char>(nm.front()))) nm.erase(nm.begin());
while (!nm.empty() && std::isspace(static_cast<unsigned char>(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<std::size_t>(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::int64_t>(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<int>(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<int>(outgoing)) || (minute != prevMin);
const std::int64_t day = chatDayKey(m.timestamp);
const bool newDay = (day != prevDay);
const bool startGroup = newDay || (prevDir != static_cast<int>(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<int>(outgoing);
prevTs = m.timestamp; prevDir = static_cast<int>(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<int>(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<int>(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<float>(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

View File

@@ -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

View File

@@ -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";