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