@@ -52,6 +52,20 @@ bool s_show_hidden = false; // when on, the list also shows hidden conversatio
bool s_show_emoji_picker = false ; // emoji picker overlay — fills the conversation-list pane while open
char s_emoji_search [ 48 ] = " " ; // emoji picker keyword filter
// Inline contact rename (Tier C): the conversation whose header name is being edited (empty = none),
// the edit buffer, and a one-shot flag to grab keyboard focus the frame the field appears.
std : : string s_rename_cid ;
char s_rename_buf [ 128 ] = " " ; // matches the Contacts tab's label editor capacity (no silent truncation)
bool s_rename_focus = false ;
// Chat customization modal (opened by the header settings "notch"). The same controls also render in
// Settings → Chat & Contacts via RenderChatSettingsControls().
bool s_show_chat_settings = false ;
// Resolved once per frame from the chat/global timestamp settings — read by formatTime() (which has no
// App access). true => 12-hour clock in the Chat tab.
bool s_chat_time_12h = false ;
// Effective draw-list font size for a material font (mirrors sidebar's ScaledFontSize).
float scaledSize ( ImFont * f ) { return f - > LegacySize * ImGui : : GetStyle ( ) . FontScaleMain ; }
@@ -75,8 +89,42 @@ std::string formatTime(std::int64_t ts) {
std : : time_t t = static_cast < std : : time_t > ( ts ) ;
std : : tm * tm = std : : localtime ( & t ) ; // UI thread only
if ( ! tm ) return " " ;
char buf [ 40 ] ;
std : : strftime ( buf , sizeof ( buf ) , s_chat_time_12h ? " %Y-%m-%d %I:%M %p " : " %Y-%m-%d %H:%M " , tm ) ;
return buf ;
}
// Time-of-day only (no date), honoring the chat clock. Used on grouped meta lines + hover (the date
// now lives in a per-day separator, so the group header doesn't repeat it). UI thread only.
std : : string chatTimeOnly ( std : : int64_t ts ) {
if ( ts < = 0 ) return " " ;
std : : time_t t = static_cast < std : : time_t > ( ts ) ;
std : : tm * tm = std : : localtime ( & t ) ;
if ( ! tm ) return " " ;
char buf [ 24 ] ;
std : : strftime ( buf , sizeof ( buf ) , s_chat_time_12h ? " %I:%M %p " : " %H:%M " , tm ) ;
return buf ;
}
// Local calendar-day key (YYYYMMDD) for detecting day boundaries between messages. UI thread only.
std : : int64_t chatDayKey ( std : : int64_t ts ) {
std : : time_t t = static_cast < std : : time_t > ( ts ) ;
std : : tm * tm = std : : localtime ( & t ) ;
if ( ! tm ) return - 1 ;
return static_cast < std : : int64_t > ( tm - > tm_year + 1900 ) * 10000 +
static_cast < std : : int64_t > ( tm - > tm_mon + 1 ) * 100 + tm - > tm_mday ;
}
// Date-separator label: "Today" / "Yesterday" / "Jul 16, 2026". UI thread only.
std : : string chatDaySeparator ( std : : int64_t ts , std : : int64_t todayKey , std : : int64_t yestKey ) {
const std : : int64_t k = chatDayKey ( ts ) ;
if ( k = = todayKey ) return TR ( " chat_today " ) ;
if ( k = = yestKey ) return TR ( " chat_yesterday " ) ;
std : : time_t t = static_cast < std : : time_t > ( ts ) ;
std : : tm * tm = std : : localtime ( & t ) ;
if ( ! tm ) return " " ;
char buf [ 32 ] ;
std : : strftime ( buf , sizeof ( buf ) , " %Y-%m- %d %H:%M " , tm ) ;
std : : strftime ( buf , sizeof ( buf ) , " %b %d, %Y " , tm ) ;
return buf ;
}
@@ -117,6 +165,18 @@ ImU32 avatarColor(const std::string& seed) {
return kPalette [ h % ( sizeof ( kPalette ) / sizeof ( kPalette [ 0 ] ) ) ] ;
}
// Outgoing-bubble accent base color for a chat_bubble_accent preset (0 = theme primary).
ImU32 bubbleAccentColor ( int accent ) {
switch ( accent ) {
case 1 : return IM_COL32 ( 0x42 , 0x85 , 0xF4 , 255 ) ; // blue
case 2 : return IM_COL32 ( 0x34 , 0xA8 , 0x53 , 255 ) ; // green
case 3 : return IM_COL32 ( 0x9C , 0x27 , 0xB0 , 255 ) ; // purple
case 4 : return IM_COL32 ( 0xFF , 0xA7 , 0x26 , 255 ) ; // amber
case 5 : return IM_COL32 ( 0xEC , 0x40 , 0x7A , 255 ) ; // pink
default : return material : : Primary ( ) ; // theme
}
}
// Uppercase first glyph of a display name (UTF-8 aware) for a letter-avatar.
std : : string initialOf ( const std : : string & name ) {
std : : size_t i = 0 ;
@@ -313,6 +373,12 @@ void RenderChatTab(App* app)
return ;
}
// Resolve the Chat-tab clock once per frame: the chat override wins, else the global format.
if ( auto * cst = app - > settings ( ) ) {
const int ctf = cst - > getChatTimeFormat ( ) ; // 0=follow global, 1=24h, 2=12h
s_chat_time_12h = ( ctf = = 2 ) | | ( ctf = = 0 & & cst - > getTimeFormat ( ) = = 1 ) ;
}
// Build conversation summaries (single scan per conversation), sorted by most-recent activity.
std : : vector < ConvSummary > convs ;
int hiddenCount = 0 ;
@@ -354,6 +420,13 @@ void RenderChatTab(App* app)
sodium_memzero ( s_compose , sizeof ( s_compose ) ) ;
s_compose_cid = s_selected_cid ;
}
// Abandon an in-progress header rename tied to a different conversation (switch/hide), so a
// half-typed buffer can't silently reappear or land on the wrong contact (Tier C review).
if ( ! s_rename_cid . empty ( ) & & s_rename_cid ! = s_selected_cid ) {
s_rename_cid . clear ( ) ;
s_rename_buf [ 0 ] = ' \0 ' ;
s_rename_focus = false ;
}
const ImVec2 avail = ImGui : : GetContentRegionAvail ( ) ;
const float listW = std : : clamp ( avail . x * 0.32f , 220.0f , 360.0f ) ;
@@ -371,39 +444,49 @@ void RenderChatTab(App* app)
// ---- Left: new-conversation button + conversation list ----
// Faint sidebar tint so the list reads as distinct from the thread pane (V6).
ImGui : : PushStyleColor ( ImGuiCol_ChildBg , ImGui : : ColorConvertU32ToFloat4 ( material : : WithAlpha ( material : : OnSurface ( ) , 10 ) ) ) ;
ImGui : : BeginChild ( " ##ChatList " , ImVec2 ( listW , avail . y ) , true ) ;
ImGui : : BeginChild ( " ##ChatList " , ImVec2 ( listW , avail . y ) , ImGuiChildFlags_Borders ,
ImGuiWindowFlags_NoScrollWithMouse ) ;
material : : ApplySmoothScroll ( ) ; // wheel-driven lerp scroll, matching the rest of the app
if ( s_show_emoji_picker ) {
// Emoji picker overlay takes over the conversation-list pane while open (search + cancel + grid).
renderEmojiPickerOverlay ( s_compose , sizeof ( s_compose ) ) ;
} else {
// Accented "New conversation " action (house tacti le style).
// "New chat " ( accented) + "Show hidden (N)" togg le, 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 conversati on count or Show hidden) .
if ( ! convs . empty ( ) | | hasHidden ) {
ImGui : : SetNextItemWidth ( - FLT_MIN ) ;
ImGui : : InputTextWithHint ( " ##chatsearch " , TR ( " chat_search " ) , s_search , sizeof ( s_search ) ) ;
}
// Show-hidden toggle — only when there are hidden conversations. Reveals them in the list (with
// an Unhide action in the thread header).
if ( hiddenCount > 0 ) {
ImGui : : PushFont ( metaFont ) ;
const std : : string lbl = s_show_hidden
? std : : string ( TR ( " chat_hide_hidden " ) )
: std : : string ( TR ( " chat_show_hidden " ) ) + " ( " + std : : to_string ( hiddenCount ) + " ) " ;
if ( ImGui : : SmallButton ( lbl . c_str ( ) ) ) s_show_hidden = ! s_show_hidden ;
ImGui : : PopFont ( ) ;
} else {
s_show_hidden = false ; // nothing hidden → keep the toggle off
}
const std : : string search = s_search ;
ImGui : : Separator ( ) ;
if ( convs . empty ( ) ) {
@@ -489,47 +572,117 @@ void RenderChatTab(App* app)
ImGui : : SameLine ( 0.0f , 10.0f * Layout : : dpiScale ( ) ) ; // breathing gutter between the list and thread panes
// ---- Right: selected conversation thread ----
ImGui : : BeginChild ( " ##ChatThread " , ImVec2 ( 0 , avail . y ) , true ) ;
{
const ConvSummary * sel = nullptr ;
for ( const auto & c : convs ) if ( c . cid = = s_selected_cid ) { sel = & c ; break ; }
// Resolve the selection up front so we can reserve room for the composer BELOW the bordered message
// box — the input sits outside/below the box now (no divider above it).
const ConvSummary * sel = nullptr ;
for ( const auto & c : convs ) if ( c . cid = = s_selected_cid ) { sel = & c ; break ; }
const float tdp = Layout : : dpiScale ( ) ;
const float composerBoxH = ImGui : : GetTextLineHeight ( ) * 2.6f ; // multi-line composer (Q6)
// Reserve a stable composer strip below the box whenever a conversation is open (thin counter row +
// the input row, which sits flush with the strip/window bottom). Same height for the waiting hint so
// the box doesn't jump when a reply arrives.
const float composerAreaH = sel ? ( composerBoxH + metaSz + 12.0f * tdp ) : 0.0f ;
ImGui : : BeginChild ( " ##ChatThread " , ImVec2 ( 0 , avail . y - composerAreaH ) , true ) ;
{
if ( sel ) {
app - > markChatConversationSeen ( sel - > cid , sel - > lastTs ) ; // viewing the thread clears its unread (Q1)
// ── Header (V7 / Tier A+B ): avatar + name on the left, a right-aligned icon toolbar,
// then a click-to-copy address with a key-verify lock (or a "waiting for reply" chip) .
// ── Header (single row ): avatar + name + key-lock + compact address ( left) , a right-aligned
// icon toolbar (right). The address rides the name line to keep the header to one row .
const float hdpi = Layout : : dpiScale ( ) ;
ImGui : : PushFont ( material : : Type ( ) . subtitle1 ( ) ) ;
const float nameH = ImGui : : GetTextLineHeight ( ) ;
const float ib = nameH + 8.0f * hdpi ; // icon-button square == header row height
const float gap = 3 .0f * hdpi ;
const float rowH = ib ;
const float avR = nameH * 0.62f ;
const ImVec2 hp = ImGui : : GetCursorScreenPos ( ) ;
ImFont * subFont = material : : Type ( ) . subtitle1 ( ) ;
const float subSz = scaledSize ( subFont ) ;
const float nameH = subSz ; // subtitle1 line height
const float ib = nameH + 8 .0f * hdpi ; // icon-button square == header row height
const float gap = 3.0f * hdpi ;
const float rowH = ib ;
const float avR = nameH * 0.62f ;
const ImVec2 hp = ImGui : : GetCursorScreenPos ( ) ;
const float rightX = hp . x + ImGui : : GetContentRegionAvail ( ) . x ;
const float textX = hp . x + 2.0f * avR + 10.0f * hdpi ;
ImDrawList * hdl = ImGui : : GetWindowDrawList ( ) ;
const ImVec2 avC ( hp . x + avR , hp . y + rowH * 0.5f ) ; // avatar centered in the row
// Avatar (centered in the row).
const ImVec2 avC ( hp . x + avR , hp . y + rowH * 0.5f ) ;
hdl - > AddCircleFilled ( avC , avR , avatarColor ( sel - > cid ) , 24 ) ;
{
const std : : string init = initialOf ( sel - > peerName ) ;
const ImVec2 isz = ImGui : : CalcTextSize ( init . c_str ( ) ) ;
hdl - > AddText ( ImVec2 ( avC . x - isz . x * 0.5f , avC . y - isz . y * 0.5f ) ,
const ImVec2 isz = subFont - > CalcTextSizeA ( subSz , FLT_MAX , 0.0f , init . c_str ( ) ) ;
hdl - > AddText ( subFont , subSz , ImVec2( avC . x - isz . x * 0.5f , avC . y - isz . y * 0.5f ) ,
IM_COL32 ( 255 , 255 , 255 , 235 ) , init . c_str ( ) ) ;
}
const bool hasAddr = ! sel - > peerZaddr . empty ( ) ;
const bool known = hasAddr & & book . findByAddress ( sel - > peerZaddr ) > = 0 ;
const bool muted = app - > settings ( ) & & app - > settings ( ) - > isChatMuted ( sel - > cid ) ;
// The toolbar's left edge is known up front (from the button count), so the name can be
// clipped to it — a long contact label can't overrun the icons (mirrors the list-pane clip).
const int nBtns = 3 + ( ( hasAddr & & ! known ) ? 1 : 0 ) ;
const bool hasAddr = ! sel - > peerZaddr . empty ( ) ;
const int bookIdx = hasAddr ? book . findByAddress ( sel - > peerZaddr ) : - 1 ;
const bool known = bookIdx > = 0 ;
const bool muted = app - > settings ( ) & & app - > settings ( ) - > isChatMuted ( sel - > cid ) ;
const bool renaming = ( s_rename_cid = = sel - > cid ) ;
// The toolbar's left edge is known up front (from the button count). A rename (edit) icon is
// shown whenever there's an address to save the contact under; the settings "notch" gear is
// always the rightmost icon.
const int nBtns = 4 + ( hasAddr ? 1 : 0 ) ;
const float toolbarLeft = rightX - ( nBtns * ib + ( nBtns - 1 ) * gap ) ;
ImGui : : SetCursorScreenPos ( ImVec2 ( textX , hp . y + ( rowH - nameH ) * 0.5f ) ) ; // name centered in row
ImGui : : PushClipRect ( ImVec2 ( textX , hp . y ) , ImVec2 ( toolbarLeft - gap , hp . y + rowH ) , true ) ;
ImGui : : TextUnformatted ( sel - > peerName . c_str ( ) ) ;
ImGui : : PopClipRect ( ) ;
ImGui : : PopFont ( ) ;
// Compact address + lock (or waiting-chip) metrics, reserved to the right of the name.
const bool showLock = hasAddr & & ! sel - > peerPubKey . empty ( ) ;
const bool showWait = hasAddr & & sel - > peerPubKey . empty ( ) ;
const std : : string sa = hasAddr ? shorten ( sel - > peerZaddr , 10 , 6 ) : std : : string ( ) ;
ImFont * icoFont = material : : Type ( ) . iconSmall ( ) ;
const float icoSz = scaledSize ( icoFont ) ;
const float addrTextW = hasAddr ? metaFont - > CalcTextSizeA ( metaSz , FLT_MAX , 0.0f , sa . c_str ( ) ) . x : 0.0f ;
const float lockW = showLock ? icoFont - > CalcTextSizeA ( icoSz , FLT_MAX , 0.0f , ICON_MD_LOCK ) . x + 5.0f * hdpi : 0.0f ;
const char * wl = showWait ? TR ( " chat_awaiting_key " ) : " " ;
const float waitTextW = showWait ? metaFont - > CalcTextSizeA ( metaSz , FLT_MAX , 0.0f , wl ) . x : 0.0f ;
const float waitChipW = showWait ? waitTextW + 14.0f * hdpi + 8.0f * hdpi : 0.0f ; // chip pad + leading gap
const float addrGroupW = lockW + addrTextW + waitChipW ;
const float nameSpacing = 12.0f * hdpi ;
const float nameCY = hp . y + ( rowH - nameH ) * 0.5f ;
const float nameW = subFont - > CalcTextSizeA ( subSz , FLT_MAX , 0.0f , sel - > peerName . c_str ( ) ) . x ;
const float nameAvail = std : : max ( 40.0f * hdpi ,
toolbarLeft - gap - textX - ( hasAddr ? addrGroupW + nameSpacing : 0.0f ) ) ;
// Name — or an inline rename field (Tier C) that takes the whole name+address span.
if ( renaming ) {
const float nameMaxW = std : : max ( 60.0f * hdpi , toolbarLeft - gap - textX ) ;
ImGui : : SetCursorScreenPos ( ImVec2 ( textX , nameCY ) ) ;
ImGui : : PushFont ( subFont ) ;
ImGui : : SetNextItemWidth ( nameMaxW ) ;
if ( s_rename_focus ) { ImGui : : SetKeyboardFocusHere ( ) ; s_rename_focus = false ; }
const bool commit = ImGui : : InputTextWithHint ( " ##renamepeer " , TR ( " chat_rename_hint " ) ,
s_rename_buf , sizeof ( s_rename_buf ) , ImGuiInputTextFlags_EnterReturnsTrue ) ;
const bool cancel = ImGui : : IsItemDeactivated ( ) & & ! commit ; // Escape or click-away
ImGui : : PopFont ( ) ;
if ( commit ) {
std : : string nm = s_rename_buf ;
while ( ! nm . empty ( ) & & std : : isspace ( static_cast < unsigned char > ( nm . front ( ) ) ) ) nm . erase ( nm . begin ( ) ) ;
while ( ! nm . empty ( ) & & std : : isspace ( static_cast < unsigned char > ( nm . back ( ) ) ) ) nm . pop_back ( ) ;
if ( ! nm . empty ( ) & & hasAddr ) {
if ( bookIdx > = 0 ) {
if ( nm ! = book . entries ( ) [ bookIdx ] . label ) { // skip a no-op rewrite/save
data : : AddressBookEntry e = book . entries ( ) [ bookIdx ] ;
e . label = nm ; // keep address/notes/scope/avatar
book . updateEntry ( static_cast < std : : size_t > ( bookIdx ) , e ) ;
book . save ( ) ;
Notifications : : instance ( ) . success ( TR ( " chat_renamed " ) ) ;
}
} else if ( book . addEntry ( data : : AddressBookEntry ( nm , sel - > peerZaddr ) ) ) {
book . save ( ) ;
Notifications : : instance ( ) . success ( TR ( " chat_contact_added " ) ) ;
} else {
Notifications : : instance ( ) . error ( TR ( " address_book_exists " ) ) ;
}
}
s_rename_cid . clear ( ) ;
} else if ( cancel ) {
s_rename_cid . clear ( ) ;
}
} else {
ImGui : : PushClipRect ( ImVec2 ( textX , hp . y ) , ImVec2 ( textX + nameAvail , hp . y + rowH ) , true ) ;
hdl - > AddText ( subFont , subSz , ImVec2 ( textX , nameCY ) , material : : OnSurface ( ) , sel - > peerName . c_str ( ) ) ;
ImGui : : PopClipRect ( ) ;
}
// Right-aligned icon toolbar: [add contact?] · export · mute · hide. Each is a frameless
// IconButton with a hover pill + tooltip (the tooltip carries the wording the old text buttons had).
@@ -544,14 +697,22 @@ void RenderChatTab(App* app)
float bx = toolbarLeft ;
const float by = hp . y ;
// Add to contacts — only when the peer isn't in the address book yet (Q2).
if ( hasAddr & & ! known ) {
// Rename (edit) the contact inline in the header. For a known peer it updates the label;
// for an unknown one it creates the contact under the typed name (folds in the old
// "add contact" quick action, but lets you name them) (Q2 / Tier C).
if ( hasAddr ) {
ImGui : : SetCursorScreenPos ( ImVec2 ( bx , by ) ) ;
material : : IconButtonStyle a = base ; a . tooltip = TR ( " chat_add_contact " ) ;
if ( material : : IconButton ( " ##hdr_add " , ICON_MD_PERSON_ADD , ifont , ImVec2 ( ib , ib ) , a ) ) {
data : : AddressBookEntry e ( sel - > peerName , se l- > 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 = materia l: : 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 - > Add Text( 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 : : G etWindowDrawList ( ) - > AddText (
tp , addrHov ? materia l : : 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 : : S etMouseCursor ( ImGuiMouseCursor_Hand ) ; material : : Tooltip ( " %s " , TR ( " chat_copy_address_tip " ) ) ; }
if ( addrClicked ) { ImGui : : SetClipboardText ( se l- > peerZaddr . c_str ( ) ) ; Notifications : : instance ( ) . success ( TR ( " copied " ) ) ; }
ax + = addrTextW ;
// Waiting-for-reply chip when the peer's identity key isn't known yet.
if ( showWait ) {
const float lead = 8.0f * hdpi , px = 7.0f * hdpi , py = 2.0f * hdpi ;
const ImVec2 cp ( ax + lead , hp . y + ( rowH - ( metaSz + 2.0f * py ) ) * 0.5f ) ;
const ImVec2 pmax ( cp . x + waitTextW + 2.0f * px , cp . y + metaSz + 2.0f * py ) ;
hdl - > AddRectFilled ( cp , pmax , material : : WithAlpha ( material : : Primary ( ) , 38 ) , ( metaSz + 2.0f * py ) * 0.5f ) ;
hdl - > AddText ( metaFont , metaSz , ImVec2 ( cp . x + px , cp . y + py ) ,
material : : WithAlpha ( material : : Primary ( ) , 230 ) , wl ) ;
ImGui : : Dummy ( ImVec2 ( wsz . x + 2.0f * px , wsz . y + 2.0f * py ) ) ;
}
ImGui : : PopFont ( ) ;
} else {
ImGui : : Dummy ( ImVec2 ( 0.0f , metaSz ) ) ; // keep the header height stable when there's no address
}
// Advance the layout cursor below the single header row, then divide it from the messages.
ImGui : : SetCursorScreenPos ( ImVec2 ( hp . x , hp . y + rowH + 4.0f * hdpi ) ) ;
ImGui : : Separator ( ) ;
const float dp = Layout : : dpiScale ( ) ;
const float composerBoxH = ImGui : : GetTextLineHeight ( ) * 2.6f ; // multi-line composer (Q6)
// Footer = composer box + the emoji-toggle/counter row (30dp tall button) + padding.
const float footerH = composerBoxH + 30.0f * dp + 20.0f * dp ;
ImGui : : BeginChild ( " ##ChatMessages " , ImVec2 ( 0 , ImGui : : GetContentRegionAvail ( ) . y - footerH ) , false ) ;
// Inset the message list from the pane edges so bubbles don't hug the border/scrollbar (8px).
// Zero the VERTICAL item spacing: the message loop reserves its own gaps via Dummy() (tight
// within a run so the grouped/merged corners read right), so the theme's 6px would double them.
const float origSpacingX = ImGui : : GetStyle ( ) . ItemSpacing . x ;
ImGui : : PushStyleVar ( ImGuiStyleVar_WindowPadding , ImVec2 ( 8.0f * dp , 8.0f * dp ) ) ;
ImGui : : PushStyleVar ( ImGuiStyleVar_ItemSpacing , ImVec2 ( origSpacingX , 0.0f ) ) ;
ImGui : : BeginChild ( " ##ChatMessages " , ImVec2 ( 0 , ImGui : : GetContentRegionAvail ( ) . y ) ,
ImGuiChildFlags_None , ImGuiWindowFlags_NoScrollWithMouse ) ;
material : : ApplySmoothScroll ( ) ; // smooth wheel scroll; syncs with the auto-scroll-to-bottom below
bool atBottom = true ;
const ImVec2 msgWinMin = ImGui : : GetWindowPos ( ) ;
const ImVec2 msgWinSize = ImGui : : GetWindowSize ( ) ;
{
ImDrawList * dl = ImGui : : GetWindowDrawList ( ) ;
const float bpad = 9.0f * dp , bround = 9.0f * dp ;
// Bubble style / accent / density / text-size from chat customization settings.
auto * cs = app - > settings ( ) ;
const int bubbleStyle = cs ? cs - > getChatBubbleStyle ( ) : 0 ; // 0 rounded, 1 square, 2 minimal
const bool compact = cs & & cs - > getChatDensity ( ) = = 1 ;
const float fscale = cs ? cs - > getChatFontScale ( ) : 1.0f ;
// Message bodies read at ~18px by default (bigger than the 15px body2 used in the list),
// then the Text-size slider (fscale) multiplies it. nameSz is body2's draw size, so
// nameSz / kFontBody2() is the DPI/font-scale factor; × 18 gives an 18px base. ImGui 1.92
// rebakes the font at the requested size, so the larger text stays crisp.
const float bodySz = nameSz * ( 18.0f / std : : max ( 1.0f , Layout : : kFontBody2 ( ) ) ) * fscale ;
const float bpad = ( compact ? 6.0f : 9.0f ) * dp ;
const float bround = ( bubbleStyle = = 1 ? 2.0f : 9.0f ) * dp ; // square vs rounded/minimal
const int outFill = ( bubbleStyle = = 2 ? 26 : 46 ) ; // minimal = fainter fill
const int inFill = ( bubbleStyle = = 2 ? 14 : 22 ) ;
const float groupGap = ( compact ? 4.0f : 7.0f ) * dp ;
const float msgGap = ( compact ? 2.0f : 3.0f ) * dp ;
const ImU32 accentBase = bubbleAccentColor ( cs ? cs - > getChatBubbleAccent ( ) : 0 ) ;
const auto messages = store . conversation ( s_selected_cid ) ;
std : : int64_t prevMin = - 1 ; int prevDir = - 1 ;
// Grouping + per-day separators (Tier 1). Same-sender messages within kGroupWindow share
// one meta header and stack tightly; a date pill is drawn once per calendar day.
const std : : int64_t nowTs = static_cast < std : : int64_t > ( std : : time ( nullptr ) ) ;
const std : : int64_t todayKey = chatDayKey ( nowTs ) ;
const std : : int64_t yestKey = chatDayKey ( nowTs - 86400 ) ;
constexpr std : : int64_t kGroupWindow = 300 ; // 5 minutes
const float avR = 11.0f * dp ; // in-thread peer avatar radius
const float gutter = 2.0f * avR + 6.0f * dp ; // incoming bubbles indent past the avatar
ImFont * icf = material : : Type ( ) . iconSmall ( ) ;
const float icfSz = scaledSize ( icf ) ;
std : : int64_t prevTs = - 1 ; int prevDir = - 1 ; std : : int64_t prevDay = - 1 ;
for ( std : : size_t mi = 0 ; mi < messages . size ( ) ; + + mi ) {
const auto & m = messages [ mi ] ;
ImGui : : PushID ( static_cast < int > ( mi ) ) ;
@@ -689,49 +877,112 @@ void RenderChatTab(App* app)
const bool request = ( m . kind = = chat : : ChatMessageKind : : ContactRequest ) ;
const bool failed = outgoing & & m . delivery = = chat : : ChatDelivery : : Failed ;
const bool sending = outgoing & & m . delivery = = chat : : ChatDelivery : : Sending ;
const std : : int64_t minute = m . timestamp / 60 ;
const bool startGroup = ( prevDir ! = static_cast < int > ( outgoing ) ) | | ( minute ! = prevMin ) ;
const std : : int64_t day = chatDayKey ( m. timestamp ) ;
const bool newDay = ( day ! = prevDay ) ;
const bool startGroup = newDay | | ( prevDir ! = static_cast < int > ( outgoing ) ) | |
( prevTs < 0 ) | | ( m . timestamp - prevTs > kGroupWindow ) ;
// Does this bubble end its run? (for grouped-corner shaping + the avatar placement)
bool lastInGroup = true ;
if ( mi + 1 < messages . size ( ) ) {
const auto & n = messages [ mi + 1 ] ;
const bool nOut = ( n . direction = = chat : : ChatDirection : : Outgoing ) ;
lastInGroup = ( chatDayKey ( n . timestamp ) ! = day ) | | ( nOut ! = outgoing ) | |
( n . timestamp - m . timestamp > kGroupWindow ) ;
}
const float availW = ImGui : : GetContentRegionAvail ( ) . x ;
const float maxBubbleW = std : : max ( 140.0f * dp , availW * 0.72f ) ;
const float innerW = maxBubbleW - 2.0f * bpad ;
// Grouped meta lin e ( sender + time), once per sender/minute run, aligned to the s end er's side .
// ── Dat e separator (once per calendar day): a c ent ered 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 = form atTime( m . timestamp ) ;
const std : : string when = ch atTimeOnly ( 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 ( name Sz, innerW , innerW , m . body . c_str ( ) ) ;
// ── Direction-aligned bubble (rounding/fill/accent from settings; grouped corners) .
const ImVec2 tsz = nameFont - > CalcTextSizeA ( body Sz, 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 " , form atTime( m . timestamp ) . c_str ( ) ) ;
const std : : string ht = ch atTimeOnly ( m . timestamp ) ;
const ImVec2 hsz = metaFont - > CalcTextSizeA ( metaSz , FLT_MAX , 0.0f , ht . c_str ( ) ) ;
const float hx = outgoing ? ( bmin . x - hsz . x - 6.0f * dp ) : ( bmax . x + 6.0f * dp ) ;
dl - > AddText ( metaFont , metaSz , ImVec2 ( hx , bmin . y + ( bh - hsz . y ) * 0.5f ) ,
material : : WithAlpha ( material : : OnSurface ( ) , 120 ) , ht . c_str ( ) ) ;
if ( ImGui : : IsMouseClicked ( ImGuiMouseButton_Right ) ) ImGui : : OpenPopup ( " ##msgmenu " ) ;
}
if ( ImGui : : BeginPopup ( " ##msgmenu " ) ) {
if ( ImGui : : MenuItem ( TR ( " copy " ) ) ) ImGui : : SetClipboardText ( m . body . c_str ( ) ) ;
ImGui : : EndPopup ( ) ;
}
// Failed send → right-aligned "not sent" + Retry (Q7).
// Delivery status for your messages: clock while sending, check once broadcast (Tier 2).
if ( outgoing & & ! failed & & ( sending | | lastInGroup ) ) {
const char * glyph = sending ? ICON_MD_SCHEDULE : ICON_MD_DONE ;
const float gs = icfSz * 0.82f ;
const ImVec2 gsz = icf - > CalcTextSizeA ( gs , FLT_MAX , 0.0f , glyph ) ;
dl - > AddText ( icf , gs , ImVec2 ( bmax . x - gsz . x , bmax . y + 1.0f * dp ) ,
material : : WithAlpha ( material : : OnSurface ( ) , sending ? 95 : 135 ) , glyph ) ;
ImGui : : Dummy ( ImVec2 ( 0.0f , gs + 2.0f * dp ) ) ; // reserve the status glyph (no ItemSpacing now)
}
// Failed send → right-aligned "not sent" + Retry (kept — it's actionable).
if ( failed ) {
ImGui : : Dummy ( ImVec2 ( 0.0f , 1.0f * dp ) ) ;
const float ftw = metaFont - > CalcTextSizeA ( metaSz , FLT_MAX , 0.0f , TR ( " chat_send_failed " ) ) . x ;
@@ -750,18 +1001,9 @@ void RenderChatTab(App* app)
s_scroll_to_cid = sel - > cid ;
}
}
// In-flight send → subtle right-aligned "sending…"; the op callback resolves it.
if ( sending ) {
ImGui : : Dummy ( ImVec2 ( 0.0f , 1.0f * dp ) ) ;
const float sw = metaFont - > CalcTextSizeA ( metaSz , FLT_MAX , 0.0f , TR ( " chat_sending " ) ) . x ;
ImGui : : SetCursorPosX ( ImGui : : GetCursorPosX ( ) + std : : max ( 0.0f , availW - sw ) ) ;
ImGui : : PushFont ( metaFont ) ; ImGui : : PushStyleColor ( ImGuiCol_Text , material : : OnSurfaceMedium ( ) ) ;
ImGui : : TextUnformatted ( TR ( " chat_sending " ) ) ;
ImGui : : PopStyleColor ( ) ; ImGui : : PopFont ( ) ;
}
ImGui : : Dummy ( ImVec2 ( 0.0f , 5.0f * dp ) ) ;
ImGui : : Dummy ( ImVec2 ( 0.0f , lastInGroup ? msgGap : msgGap * 0.5f ) ) ; // tight within a run
ImGui : : PopID ( ) ;
prevMin = minute ; prevDir = static_cast < int > ( outgoing ) ;
prevTs = m. timestamp ; prevDir = static_cast < int > ( outgoing ) ; prevDay = day ;
}
if ( s_scroll_to_cid = = s_selected_cid ) {
ImGui : : SetScrollHereY ( 1.0f ) ;
@@ -770,6 +1012,7 @@ void RenderChatTab(App* app)
atBottom = ImGui : : GetScrollY ( ) > = ImGui : : GetScrollMaxY ( ) - 4.0f ;
}
ImGui : : EndChild ( ) ;
ImGui : : PopStyleVar ( 2 ) ; // ##ChatMessages WindowPadding + zeroed vertical ItemSpacing
// Jump-to-latest pill when scrolled up (Q9). SetCursorScreenPos moves the parent cursor to
// the child's bottom edge; save + restore it so the composer footer below stays anchored
// (otherwise it renders ~8px too high only while the pill is shown).
@@ -783,71 +1026,7 @@ void RenderChatTab(App* app)
ImGui : : PopStyleColor ( 2 ) ;
ImGui : : SetCursorScreenPos ( savedCursor ) ;
}
// Composer footer: message input + send (only once we know the peer's key), else a hint.
ImGui : : Separator ( ) ;
if ( sel - > peerPubKey . empty ( ) ) {
ImGui : : PushFont ( metaFont ) ;
ImGui : : PushStyleColor ( ImGuiCol_Text , material : : OnSurfaceMedium ( ) ) ;
ImGui : : PushTextWrapPos ( 0.0f ) ;
ImGui : : TextUnformatted ( TR ( " chat_waiting_reply " ) ) ;
ImGui : : PopTextWrapPos ( ) ;
ImGui : : PopStyleColor ( ) ;
ImGui : : PopFont ( ) ;
} else {
// Multi-line composer (Q6): Enter sends, Ctrl+Enter inserts a newline. A live byte counter
// tracks the effective on-chain body cap = (512 − len("utf8:"))/2 − secretstream ABYTES.
static constexpr int kBodyMaxBytes = ( 512 - 5 ) / 2 - 17 ; // = 236 (see chat_outgoing.cpp)
const float sendW = 80.0f * Layout : : dpiScale ( ) ; // scaled so the translated label never clips (B3)
const float inputW = ImGui : : GetContentRegionAvail ( ) . x - sendW - ImGui : : GetStyle ( ) . ItemSpacing . x ;
const int used = static_cast < int > ( std : : strlen ( s_compose ) ) ;
const bool overCap = used > kBodyMaxBytes ;
bool submit = ImGui : : InputTextMultiline ( " ##compose " , s_compose , sizeof ( s_compose ) ,
ImVec2 ( inputW , composerBoxH ) ,
ImGuiInputTextFlags_EnterReturnsTrue | ImGuiInputTextFlags_CtrlEnterForNewLine ) ;
ImGui : : SameLine ( ) ;
ImGui : : PushStyleColor ( ImGuiCol_Button , ImGui : : ColorConvertU32ToFloat4 ( material : : WithAlpha ( material : : Primary ( ) , 205 ) ) ) ;
ImGui : : PushStyleColor ( ImGuiCol_ButtonHovered , ImGui : : ColorConvertU32ToFloat4 ( material : : Primary ( ) ) ) ;
ImGui : : PushStyleColor ( ImGuiCol_ButtonActive , ImGui : : ColorConvertU32ToFloat4 ( material : : WithAlpha ( material : : Primary ( ) , 235 ) ) ) ;
ImGui : : BeginDisabled ( overCap | | s_compose [ 0 ] = = ' \0 ' ) ;
if ( material : : TactileButton ( TR ( " chat_send " ) , ImVec2 ( sendW , composerBoxH ) ) ) submit = true ;
ImGui : : EndDisabled ( ) ;
ImGui : : PopStyleColor ( 3 ) ;
// Emoji picker button (left) + byte counter (right) on one row. The picker opens as an
// overlay over the conversation-list pane (rendered there), not a floating window. The
// toggle is a larger frameless button (Tier A) that lights up while the picker is open.
const float emojiBtn = 30.0f * dp ;
{
material : : IconButtonStyle es ;
es . hoverBg = material : : WithAlpha ( material : : OnSurface ( ) , 30 ) ;
es . bgRounding = 8.0f * dp ;
if ( s_show_emoji_picker ) es . restBg = material : : WithAlpha ( material : : Primary ( ) , 46 ) ;
if ( material : : IconButton ( " ##emojitoggle " , u8 " 🙂 " , material : : Type ( ) . subtitle1 ( ) ,
ImVec2 ( emojiBtn , emojiBtn ) , es ) ) {
s_show_emoji_picker = ! s_show_emoji_picker ;
s_emoji_search [ 0 ] = ' \0 ' ;
}
}
ImGui : : SameLine ( ) ;
// Byte counter (Error tint over cap) with the over-cap label — vertically centered on the button.
ImGui : : PushFont ( metaFont ) ;
ImGui : : PushStyleColor ( ImGuiCol_Text , overCap ? material : : Error ( ) : material : : OnSurfaceMedium ( ) ) ;
ImGui : : SetCursorPosY ( ImGui : : GetCursorPosY ( ) +
std : : max ( 0.0f , ( emojiBtn - ImGui : : GetTextLineHeight ( ) ) * 0.5f ) ) ;
if ( overCap ) { ImGui : : TextUnformatted ( TR ( " chat_len_over " ) ) ; ImGui : : SameLine ( ) ; }
const std : : string counter = std : : to_string ( used ) + " / " + std : : to_string ( kBodyMaxBytes ) ;
const float cw = metaFont - > CalcTextSizeA ( metaSz , FLT_MAX , 0.0f , counter . c_str ( ) ) . x ;
ImGui : : SetCursorPosX ( ImGui : : GetCursorPosX ( ) +
std : : max ( 0.0f , ImGui : : GetContentRegionAvail ( ) . x - cw ) ) ;
ImGui : : TextUnformatted ( counter . c_str ( ) ) ;
ImGui : : PopStyleColor ( ) ;
ImGui : : PopFont ( ) ;
if ( submit & & s_compose [ 0 ] ! = ' \0 ' & & ! overCap ) {
app - > sendChatMessage ( sel - > cid , s_compose ) ;
sodium_memzero ( s_compose , sizeof ( s_compose ) ) ;
s_scroll_to_cid = sel - > cid ;
}
}
// Composer is rendered OUTSIDE this box, directly below it (see after EndChild).
} else {
if ( convs . empty ( ) ) centeredEmptyState ( ICON_MD_FORUM , TR ( " chat_empty_title " ) , TR ( " chat_empty_start " ) ) ;
else centeredEmptyState ( ICON_MD_CHAT_BUBBLE_OUTLINE , TR ( " chat_select_hint " ) , nullptr ) ;
@@ -855,6 +1034,92 @@ void RenderChatTab(App* app)
}
ImGui : : EndChild ( ) ;
// ── Composer — OUTSIDE / below the bordered message box, spanning its width. No divider above it.
// Layout (all placed by absolute screen pos so nothing wraps to the window's left edge): a thin
// byte-counter row just under the box, then the input row flush with the strip/window bottom, with
// the emoji toggle on the LEFT of the input and Send on the right.
if ( sel ) {
const ImVec2 boxMin = ImGui : : GetItemRectMin ( ) ; // the ##ChatThread child's rect
const ImVec2 boxMax = ImGui : : GetItemRectMax ( ) ;
const float cx = boxMin . x ;
const float cw = boxMax . x - boxMin . x ;
if ( sel - > peerPubKey . empty ( ) ) {
ImGui : : SetCursorScreenPos ( ImVec2 ( cx , boxMax . y + 8.0f * tdp ) ) ;
ImGui : : PushFont ( metaFont ) ;
ImGui : : PushStyleColor ( ImGuiCol_Text , material : : OnSurfaceMedium ( ) ) ;
ImGui : : PushTextWrapPos ( 0.0f ) ;
ImGui : : TextUnformatted ( TR ( " chat_waiting_reply " ) ) ;
ImGui : : PopTextWrapPos ( ) ;
ImGui : : PopStyleColor ( ) ;
ImGui : : PopFont ( ) ;
} else {
// Multi-line composer (Q6). Enter behavior is setting-dependent (see below). A live byte
// counter tracks the on-chain body cap = (512 − len("utf8:"))/2 − secretstream ABYTES.
static constexpr int kBodyMaxBytes = ( 512 - 5 ) / 2 - 17 ; // = 236 (see chat_outgoing.cpp)
const float sendW = 80.0f * tdp ; // scaled so the translated label never clips (B3)
const float emojiBtn = 28.0f * tdp ;
const float inGap = 6.0f * tdp ;
const int used = static_cast < int > ( std : : strlen ( s_compose ) ) ;
const bool overCap = used > kBodyMaxBytes ;
// Thin counter row (right-aligned) just under the box; over-cap tints + prefixes a label.
std : : string counterLine = std : : to_string ( used ) + " / " + std : : to_string ( kBodyMaxBytes ) ;
if ( overCap ) counterLine = std : : string ( TR ( " chat_len_over " ) ) + " " + counterLine ;
const float cwid = metaFont - > CalcTextSizeA ( metaSz , FLT_MAX , 0.0f , counterLine . c_str ( ) ) . x ;
ImGui : : GetWindowDrawList ( ) - > AddText ( metaFont , metaSz ,
ImVec2 ( cx + cw - cwid , boxMax . y + 3.0f * tdp ) ,
overCap ? material : : Error ( ) : material : : OnSurfaceMedium ( ) , counterLine . c_str ( ) ) ;
// Input row, flush with the strip/window bottom: [emoji] [input] [Send].
const float rowY = boxMax . y + composerAreaH - composerBoxH ;
const float inputX = cx + emojiBtn + inGap ;
const float inputW = std : : max ( 60.0f * tdp , cw - emojiBtn - 2.0f * inGap - sendW ) ;
const float sendX = inputX + inputW + inGap ;
// Emoji toggle on the left, vertically centered in the input row.
ImGui : : SetCursorScreenPos ( ImVec2 ( cx , rowY + ( composerBoxH - emojiBtn ) * 0.5f ) ) ;
{
material : : IconButtonStyle es ;
es . hoverBg = material : : WithAlpha ( material : : OnSurface ( ) , 30 ) ;
es . bgRounding = 8.0f * tdp ;
if ( s_show_emoji_picker ) es . restBg = material : : WithAlpha ( material : : Primary ( ) , 46 ) ;
if ( material : : IconButton ( " ##emojitoggle " , u8 " 🙂 " , material : : Type ( ) . subtitle1 ( ) ,
ImVec2 ( emojiBtn , emojiBtn ) , es ) ) {
s_show_emoji_picker = ! s_show_emoji_picker ;
s_emoji_search [ 0 ] = ' \0 ' ;
}
}
// Input.
ImGui : : SetCursorScreenPos ( ImVec2 ( inputX , rowY ) ) ;
// Enter-to-send (setting): on → Enter sends + Ctrl+Enter newline; off → Enter is a newline and
// the Send button is the only way to send. NOTE: without EnterReturnsTrue, InputTextMultiline
// returns true on EVERY edit (value-changed), so in off-mode the return value must NOT drive
// submit — otherwise typing one character would fire a send.
const bool enterSends = ! app - > settings ( ) | | app - > settings ( ) - > getChatEnterSends ( ) ;
const ImGuiInputTextFlags composeFlags = enterSends
? ( ImGuiInputTextFlags_EnterReturnsTrue | ImGuiInputTextFlags_CtrlEnterForNewLine )
: ImGuiInputTextFlags_None ;
const bool inputReturned = ImGui : : InputTextMultiline ( " ##compose " , s_compose , sizeof ( s_compose ) ,
ImVec2 ( inputW , composerBoxH ) , composeFlags ) ;
bool submit = enterSends & & inputReturned ; // off-mode sends only via the Send button
// Send.
ImGui : : SetCursorScreenPos ( ImVec2 ( sendX , rowY ) ) ;
ImGui : : PushStyleColor ( ImGuiCol_Button , ImGui : : ColorConvertU32ToFloat4 ( material : : WithAlpha ( material : : Primary ( ) , 205 ) ) ) ;
ImGui : : PushStyleColor ( ImGuiCol_ButtonHovered , ImGui : : ColorConvertU32ToFloat4 ( material : : Primary ( ) ) ) ;
ImGui : : PushStyleColor ( ImGuiCol_ButtonActive , ImGui : : ColorConvertU32ToFloat4 ( material : : WithAlpha ( material : : Primary ( ) , 235 ) ) ) ;
ImGui : : BeginDisabled ( overCap | | s_compose [ 0 ] = = ' \0 ' ) ;
if ( material : : TactileButton ( TR ( " chat_send " ) , ImVec2 ( sendW , composerBoxH ) ) ) submit = true ;
ImGui : : EndDisabled ( ) ;
ImGui : : PopStyleColor ( 3 ) ;
if ( submit & & s_compose [ 0 ] ! = ' \0 ' & & ! overCap ) {
app - > sendChatMessage ( sel - > cid , s_compose ) ;
sodium_memzero ( s_compose , sizeof ( s_compose ) ) ;
s_scroll_to_cid = sel - > cid ;
}
}
}
// ---- New-conversation dialog (send a contact request to a z-address) — house BlurFloat overlay ----
if ( s_show_new_convo ) {
const float dp = Layout : : dpiScale ( ) ;
@@ -923,6 +1188,26 @@ void RenderChatTab(App* app)
material : : EndOverlayDialog ( ) ;
}
}
// ---- Chat customization modal (opened by the header settings "notch") — house BlurFloat overlay ----
if ( s_show_chat_settings ) {
material : : OverlayDialogSpec ov ;
ov . title = TR ( " chat_settings_title " ) ;
ov . p_open = & s_show_chat_settings ; // X / backdrop closes it
ov . style = material : : OverlayStyle : : BlurFloat ;
ov . cardWidth = 520.0f ; ov . idSuffix = " chatsettings " ;
if ( material : : BeginOverlayDialog ( ov ) ) {
RenderChatSettingsControls ( app ) ;
ImGui : : Dummy ( ImVec2 ( 0.0f , 8.0f * Layout : : dpiScale ( ) ) ) ;
ImGui : : PushStyleColor ( ImGuiCol_Button , ImGui : : ColorConvertU32ToFloat4 ( material : : WithAlpha ( material : : Primary ( ) , 205 ) ) ) ;
ImGui : : PushStyleColor ( ImGuiCol_ButtonHovered , ImGui : : ColorConvertU32ToFloat4 ( material : : Primary ( ) ) ) ;
ImGui : : PushStyleColor ( ImGuiCol_ButtonActive , ImGui : : ColorConvertU32ToFloat4 ( material : : WithAlpha ( material : : Primary ( ) , 235 ) ) ) ;
if ( material : : TactileButton ( TR ( " chat_settings_done " ) , ImVec2 ( ImGui : : GetContentRegionAvail ( ) . x , 0 ) ) )
s_show_chat_settings = false ;
ImGui : : PopStyleColor ( 3 ) ;
material : : EndOverlayDialog ( ) ;
}
}
}
void ResetChatTab ( )
@@ -932,10 +1217,155 @@ void ResetChatTab()
sodium_memzero ( s_compose , sizeof ( s_compose ) ) ;
sodium_memzero ( s_new_zaddr , sizeof ( s_new_zaddr ) ) ;
sodium_memzero ( s_new_msg , sizeof ( s_new_msg ) ) ;
sodium_memzero ( s_rename_buf , sizeof ( s_rename_buf ) ) ;
s_selected_cid . clear ( ) ;
s_scroll_to_cid . clear ( ) ;
s_compose_cid . clear ( ) ;
s_rename_cid . clear ( ) ;
s_rename_focus = false ;
s_show_new_convo = false ;
s_show_chat_settings = false ;
}
void RenderChatSettingsControls ( App * app , float contentWidth )
{
auto * st = app ? app - > settings ( ) : nullptr ;
if ( ! st ) return ;
const float dp = Layout : : dpiScale ( ) ;
const float ctrlW = 250.0f * dp ; // control column width (fits a 3-segment control comfortably)
const float rowGap = 5.0f * dp ;
// Right-align controls to the row's true right edge. The Settings tab renders us inside a GlassCard
// whose content region isn't narrowed to the card padding, so it passes an explicit contentWidth;
// the chat modal's dialog content region is correct, so it passes 0 (auto).
const float leftX = ImGui : : GetCursorPosX ( ) ;
const float rowW = ( contentWidth > 0.0f ) ? contentWidth : ImGui : : GetContentRegionAvail ( ) . x ;
// Label left, control right-aligned within [leftX, leftX+rowW]. Leaves the cursor at the control origin.
auto beginRow = [ & ] ( const char * label ) {
ImGui : : Dummy ( ImVec2 ( 0.0f , rowGap ) ) ;
ImGui : : AlignTextToFramePadding ( ) ;
ImGui : : TextUnformatted ( label ) ;
ImGui : : SameLine ( ) ;
ImGui : : SetCursorPosX ( std : : max ( ImGui : : GetCursorPosX ( ) , leftX + std : : max ( 0.0f , rowW - ctrlW ) ) ) ;
ImGui : : SetNextItemWidth ( ctrlW ) ;
} ;
auto section = [ & ] ( const char * key ) {
ImGui : : Dummy ( ImVec2 ( 0.0f , 8.0f * dp ) ) ;
ImGui : : PushFont ( material : : Type ( ) . caption ( ) ) ;
ImGui : : PushStyleColor ( ImGuiCol_Text , material : : WithAlpha ( material : : Primary ( ) , 235 ) ) ;
ImGui : : TextUnformatted ( TR ( key ) ) ;
ImGui : : PopStyleColor ( ) ;
ImGui : : PopFont ( ) ;
ImGui : : Dummy ( ImVec2 ( 0.0f , 2.0f * dp ) ) ;
} ;
// Segmented control (iOS-style): a rounded track with an inset pill on the selected segment.
// Returns the (possibly changed) index. Reads the label via beginRow for a right-aligned control.
auto segmented = [ & ] ( const char * label , const char * const * items , int count , int value ) - > int {
beginRow ( label ) ;
const ImVec2 origin = ImGui : : GetCursorScreenPos ( ) ;
const float h = ImGui : : GetFrameHeight ( ) ;
const float seg = ctrlW / static_cast < float > ( count ) ;
const float round = 7.0f * dp ;
ImDrawList * dl = ImGui : : GetWindowDrawList ( ) ;
dl - > AddRectFilled ( origin , ImVec2 ( origin . x + ctrlW , origin . y + h ) ,
material : : WithAlpha ( material : : OnSurface ( ) , 20 ) , round ) ;
int result = value ;
ImGui : : PushID ( label ) ;
for ( int i = 0 ; i < count ; + + i ) {
ImGui : : PushID ( i ) ;
const ImVec2 mn ( origin . x + i * seg , origin . y ) , mx ( origin . x + ( i + 1 ) * seg , origin . y + h ) ;
ImGui : : SetCursorScreenPos ( mn ) ;
if ( ImGui : : InvisibleButton ( " ##s " , ImVec2 ( seg , h ) ) ) result = i ;
const bool hov = ImGui : : IsItemHovered ( ) ;
if ( hov ) ImGui : : SetMouseCursor ( ImGuiMouseCursor_Hand ) ;
const bool sel = ( value = = i ) ;
if ( sel ) {
const float in = 2.0f * dp ;
dl - > AddRectFilled ( ImVec2 ( mn . x + in , mn . y + in ) , ImVec2 ( mx . x - in , mx . y - in ) ,
material : : WithAlpha ( material : : Primary ( ) , 210 ) , std : : max ( 1.0f , round - in ) ) ;
} else if ( hov ) {
dl - > AddRectFilled ( mn , mx , material : : WithAlpha ( material : : OnSurface ( ) , 26 ) , round ) ;
}
const ImVec2 ts = ImGui : : CalcTextSize ( items [ i ] ) ;
// Center when the label fits; otherwise left-align with a small pad so the START stays
// readable (a long translation clipped on the right, not clipped on BOTH sides).
const float lpad = 4.0f * dp ;
const float tx = ( ts . x < = seg - 2.0f * lpad ) ? ( seg - ts . x ) * 0.5f : lpad ;
ImGui : : PushClipRect ( mn , mx , true ) ;
dl - > AddText ( ImVec2 ( mn . x + tx , mn . y + ( h - ts . y ) * 0.5f ) ,
sel ? IM_COL32 ( 255 , 255 , 255 , 236 ) : material : : OnSurfaceMedium ( ) , items [ i ] ) ;
ImGui : : PopClipRect ( ) ;
ImGui : : PopID ( ) ;
}
ImGui : : PopID ( ) ;
ImGui : : SetCursorScreenPos ( origin ) ;
ImGui : : Dummy ( ImVec2 ( ctrlW , h ) ) ; // reserve the control's rect for layout flow
return result ;
} ;
// ── Appearance ────────────────────────────────────────────────────────────────
section ( " chat_sec_appearance " ) ;
// Emoji style (monochrome / color). Color needs a FreeType build (native + the cross-built Windows
// FreeType); requestFontRebuild swaps the atlas live.
{
int v = st - > getChatEmojiColor ( ) ? 1 : 0 ;
const char * items [ ] = { TR ( " chat_emoji_mono " ) , TR ( " chat_emoji_color " ) } ;
int nv = segmented ( TR ( " chat_opt_emoji " ) , items , 2 , v ) ;
if ( nv ! = v ) { st - > setChatEmojiColor ( nv = = 1 ) ; st - > save ( ) ; app - > requestFontRebuild ( ) ; }
}
// Bubble style (segmented) + accent color (a 6-way dropdown — too many for a segmented control).
{
const char * items [ ] = { TR ( " chat_bubble_rounded " ) , TR ( " chat_bubble_square " ) , TR ( " chat_bubble_minimal " ) } ;
int v = st - > getChatBubbleStyle ( ) ;
int nv = segmented ( TR ( " chat_opt_bubble_style " ) , items , 3 , v ) ;
if ( nv ! = v ) { st - > setChatBubbleStyle ( nv ) ; st - > save ( ) ; }
}
{
beginRow ( TR ( " chat_opt_bubble_accent " ) ) ;
int v = st - > getChatBubbleAccent ( ) ;
const char * items [ ] = { TR ( " chat_accent_theme " ) , TR ( " chat_accent_blue " ) , TR ( " chat_accent_green " ) ,
TR ( " chat_accent_purple " ) , TR ( " chat_accent_amber " ) , TR ( " chat_accent_pink " ) } ;
if ( ImGui : : Combo ( " ##chat_baccent " , & v , items , 6 ) ) { st - > setChatBubbleAccent ( v ) ; st - > save ( ) ; }
}
// Message density (segmented).
{
const char * items [ ] = { TR ( " chat_density_comfortable " ) , TR ( " chat_density_compact " ) } ;
int v = st - > getChatDensity ( ) ;
int nv = segmented ( TR ( " chat_opt_density " ) , items , 2 , v ) ;
if ( nv ! = v ) { st - > setChatDensity ( nv ) ; st - > save ( ) ; }
}
// Message text size (slider).
{
beginRow ( TR ( " chat_opt_font_size " ) ) ;
float v = st - > getChatFontScale ( ) ;
if ( ImGui : : SliderFloat ( " ##chat_font " , & v , 0.8f , 1.5f , " %.2fx " , ImGuiSliderFlags_AlwaysClamp ) ) {
st - > setChatFontScale ( v ) ; st - > save ( ) ;
}
}
// ── Messaging ─────────────────────────────────────────────────────────────────
section ( " chat_sec_messaging " ) ;
// Message poll rate (full-node 0-conf fast-scan cadence) — slider.
{
beginRow ( TR ( " chat_opt_poll " ) ) ;
float v = st - > getChatPollRateSec ( ) ;
if ( ImGui : : SliderFloat ( " ##chat_poll " , & v , 0.5f , 15.0f , " %.1f s " , ImGuiSliderFlags_AlwaysClamp ) ) {
st - > setChatPollRateSec ( v ) ; st - > save ( ) ;
}
}
// Chat timestamps (segmented) — overrides the app-wide clock (Settings → General) for this tab only.
{
const char * items [ ] = { TR ( " chat_ts_global_short " ) , TR ( " chat_ts_24h " ) , TR ( " chat_ts_12h " ) } ;
int v = st - > getChatTimeFormat ( ) ;
int nv = segmented ( TR ( " chat_opt_timestamp " ) , items , 3 , v ) ;
if ( nv ! = v ) { st - > setChatTimeFormat ( nv ) ; st - > save ( ) ; }
}
// Enter-to-send (checkbox).
{
ImGui : : Dummy ( ImVec2 ( 0.0f , rowGap ) ) ;
bool v = st - > getChatEnterSends ( ) ;
if ( ImGui : : Checkbox ( TR ( " chat_opt_enter_sends " ) , & v ) ) { st - > setChatEnterSends ( v ) ; st - > save ( ) ; }
}
}
} // namespace ui