Skip to content

Commit 50d083a

Browse files
committed
feat: implement smart group chat avatars and layout fixes
- Implement smart avatar display: show avatar only for first message in sender sequence - Add alignment padding for grouped messages to maintain visual structure - Fix avatar text alignment with manual centering for TUI - Add bottom spacer to message list to prevent input bar overlap - Refactor sender identification logic into getSenderInfo helper
1 parent 6b74e88 commit 50d083a

1 file changed

Lines changed: 114 additions & 24 deletions

File tree

src/views/ConversationView.ts

Lines changed: 114 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -249,14 +249,34 @@ export function ConversationView() {
249249
conversationScrollBox.add(emptyText)
250250
} else {
251251
let lastDateLabel = ""
252+
let lastSenderId = ""
253+
252254
for (const message of reversedMessages) {
255+
// Date Separator
253256
const dateLabel = formatDateSeparator(message.timestamp)
254257
if (dateLabel !== lastDateLabel) {
255258
conversationScrollBox.add(DaySeparator(renderer, dateLabel))
256259
lastDateLabel = dateLabel
260+
// Reset sender grouping on new day
261+
lastSenderId = ""
257262
}
258-
conversationScrollBox.add(renderMessage(renderer, message, isGroup))
263+
264+
// Determine if this is the start of a new sequence of messages from the same user
265+
const { senderId } = getSenderInfo(message, isGroup)
266+
const isSequenceStart = senderId !== lastSenderId
267+
268+
conversationScrollBox.add(renderMessage(renderer, message, isGroup, isSequenceStart))
269+
270+
// Update last sender
271+
lastSenderId = senderId
259272
}
273+
274+
// Add a spacer at the bottom to prevent messages from being "crushed" by the input bar
275+
const spacer = new BoxRenderable(renderer, {
276+
height: 1,
277+
backgroundColor: WhatsAppTheme.deepDark,
278+
})
279+
conversationScrollBox.add(spacer)
260280
}
261281

262282
// Message input field (bottom)
@@ -760,49 +780,71 @@ function renderReplyContext(
760780
return contextBox
761781
}
762782

763-
function renderMessage(
764-
renderer: CliRenderer,
783+
// Helper to get sender info (ID, Name, Color)
784+
function getSenderInfo(
765785
message: WAMessageExtended,
766-
isGroupChat: boolean = false
767-
): BoxRenderable {
786+
isGroupChat: boolean
787+
): { senderId: string; senderName: string; senderColor: string } {
768788
const isFromMe = message.fromMe
769-
const timestamp = new Date(message.timestamp * 1000).toLocaleTimeString([], {
770-
hour: "2-digit",
771-
minute: "2-digit",
772-
})
773-
774-
// Extract sender name for group chats (only for received messages)
775789
let senderName = ""
776790
let senderId = ""
777-
if (isGroupChat && !isFromMe && message.from) {
778-
const fromParts = message.from.split("@")
779-
senderName = fromParts[0] // Use phone number as fallback
780-
senderId = message.from
781791

782-
// If message has participant field (for group messages), use that
783-
if (message.participant) {
792+
if (isFromMe) {
793+
senderName = "You"
794+
senderId = appState.getState().myProfile?.id || "me"
795+
} else {
796+
senderId = message.from || ""
797+
if (isGroupChat && message.participant) {
784798
senderId = message.participant
799+
}
800+
801+
// Determine name
802+
if (isGroupChat && message.from) {
803+
const fromParts = message.from.split("@")
804+
senderName = fromParts[0] // Fallback
785805

786-
// Priority 1: Check contacts cache (user-saved names)
806+
// Priority 1: Check contacts cache
787807
const cachedName = appState.getContactName(senderId)
788808
if (cachedName) {
789809
senderName = cachedName
790810
} else {
791-
// Priority 2: Try to get display name from _data
811+
// Priority 2: _data info
792812
const msgData = message._data
793813
if (msgData?.notifyName) {
794814
senderName = msgData.notifyName
795815
} else if (msgData?.pushName) {
796816
senderName = msgData.pushName
797817
} else {
798-
// Priority 3: Fallback to participant ID
818+
// Priority 3: Participant ID parts
799819
const participantParts = senderId.split("@")
800820
senderName = participantParts[0]
801821
}
802822
}
823+
} else {
824+
// 1:1 Chat sender name
825+
senderName = appState.getContactName(senderId) || senderId.split("@")[0]
803826
}
804827
}
805828

829+
const senderColor = isFromMe ? WhatsAppTheme.green : getSenderColor(senderId)
830+
831+
return { senderId, senderName, senderColor }
832+
}
833+
834+
function renderMessage(
835+
renderer: CliRenderer,
836+
message: WAMessageExtended,
837+
isGroupChat: boolean = false,
838+
isSequenceStart: boolean = true
839+
): BoxRenderable {
840+
const isFromMe = message.fromMe
841+
const timestamp = new Date(message.timestamp * 1000).toLocaleTimeString([], {
842+
hour: "2-digit",
843+
minute: "2-digit",
844+
})
845+
846+
const { senderName, senderColor } = getSenderInfo(message, isGroupChat)
847+
806848
// Build message bubble content with WhatsApp-like layout
807849
const messageText = message.body || "(media)"
808850
const timestampText = t`${timestamp}${isFromMe ? formatAckStatus(message.ack, {}) : ""}`
@@ -812,9 +854,50 @@ function renderMessage(
812854
id: `msg-${message.id || Date.now()}-row`,
813855
flexDirection: "row",
814856
justifyContent: isFromMe ? "flex-end" : "flex-start",
815-
marginBottom: 1,
857+
marginBottom: 0, // Tight spacing for grouped messages
858+
marginTop: isSequenceStart ? 1 : 0, // Add spacing only between groups
816859
})
817860

861+
// Avatar Column (Only for received messages in Group Chats)
862+
if (isGroupChat && !isFromMe) {
863+
const avatarColumn = new BoxRenderable(renderer, {
864+
width: 6, // Match avatarBox width
865+
height: 3, // Approximate height of avatar
866+
marginRight: 1, // Gap between avatar column and message bubble
867+
flexDirection: "column",
868+
justifyContent: "center",
869+
alignItems: "center",
870+
})
871+
872+
if (isSequenceStart) {
873+
// Show Avatar
874+
const avatarBox = new BoxRenderable(renderer, {
875+
width: 6, // Wider avatar box
876+
height: 3, // Match column height for vertical centering
877+
backgroundColor: senderColor,
878+
justifyContent: "center",
879+
alignItems: "center",
880+
})
881+
const initials = getInitials(senderName)
882+
// Manually center text for TUI (width 6)
883+
const centeredInitials = centerText(initials, 6)
884+
885+
avatarBox.add(
886+
new TextRenderable(renderer, {
887+
content: centeredInitials,
888+
fg: WhatsAppTheme.white,
889+
attributes: TextAttributes.BOLD,
890+
})
891+
)
892+
avatarColumn.add(avatarBox)
893+
} else {
894+
// Empty placeholder for padding
895+
// Just an empty box or nothing, but width ensures alignment
896+
}
897+
898+
row.add(avatarColumn)
899+
}
900+
818901
// Create bubble container
819902
// Capture message reference for context menu click handler
820903
const msgRef = message
@@ -862,8 +945,8 @@ function renderMessage(
862945
},
863946
})
864947

865-
// Row 1: Sender name (only for group chat received messages)
866-
if (senderName) {
948+
// Row 1: Sender name (only for group chat received messages AND first in sequence)
949+
if (isGroupChat && !isFromMe && isSequenceStart) {
867950
const senderRow = new BoxRenderable(renderer, {
868951
id: `msg-${message.id || Date.now()}-sender`,
869952
height: 1,
@@ -872,7 +955,7 @@ function renderMessage(
872955
})
873956
const senderText = new TextRenderable(renderer, {
874957
content: senderName,
875-
fg: getSenderColor(senderId),
958+
fg: senderColor,
876959
attributes: TextAttributes.BOLD,
877960
})
878961
senderRow.add(senderText)
@@ -985,3 +1068,10 @@ function DaySeparator(renderer: CliRenderer, label: string): BoxRenderable {
9851068
container.add(badge)
9861069
return container
9871070
}
1071+
1072+
// Helper to center text in a fixed width
1073+
function centerText(text: string, width: number): string {
1074+
if (text.length >= width) return text.slice(0, width)
1075+
const paddingLeft = Math.floor((width - text.length) / 2)
1076+
return text.padStart(text.length + paddingLeft).padEnd(width)
1077+
}

0 commit comments

Comments
 (0)