feat: Add avatar circles to channel messages

- Add mini avatars next to sender names in channel chat
- Extract emoji from username for avatar (first emoji only)
- Use initials for users without emoji (1-2 letters)
- Generate consistent colors based on username hash
- Move sender name outside message bubble (MeshCore style)
- Add responsive styles for mobile devices

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
MarekWo
2026-01-20 07:38:15 +01:00
parent 731d4a9d9b
commit 5582f85ad0
2 changed files with 227 additions and 51 deletions
+108 -26
View File
@@ -38,14 +38,103 @@ main {
gap: 0.75rem;
}
/* =============================================================================
Message Wrapper - New Layout with Avatar
============================================================================= */
/* Message wrapper - contains avatar + message container */
.message-wrapper {
display: flex;
gap: 0.5rem;
max-width: 85%;
animation: fadeIn 0.3s ease-in;
}
.message-wrapper.own {
align-self: flex-end;
flex-direction: row-reverse;
}
.message-wrapper.other {
align-self: flex-start;
}
/* Avatar circle */
.message-avatar {
flex-shrink: 0;
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.85rem;
font-weight: 600;
color: white;
text-transform: uppercase;
}
.message-avatar.emoji {
font-size: 1.25rem;
background-color: transparent !important;
}
/* Message container - holds sender row + bubble + actions */
.message-container {
display: flex;
flex-direction: column;
min-width: 0; /* Allow text truncation */
}
/* Sender row - name and time above bubble */
.message-sender-row {
display: flex;
align-items: baseline;
gap: 0.5rem;
margin-bottom: 0.25rem;
padding-left: 0.25rem;
}
.message-sender {
font-weight: 600;
font-size: 0.875rem;
color: #0d6efd;
}
.message-sender-row .message-time {
font-size: 0.7rem;
color: #6c757d;
}
/* Message footer for own messages (time below bubble) */
.message-footer {
display: flex;
padding-right: 0.5rem;
margin-top: 0.25rem;
}
.message-footer.own {
justify-content: flex-end;
}
.message-footer .message-time {
font-size: 0.7rem;
color: #6c757d;
}
/* Message action buttons container */
.message-actions {
display: flex;
gap: 0.25rem;
margin-top: 0.25rem;
}
/* Message Bubbles */
.message {
max-width: 70%;
padding: 0.75rem 1rem;
border-radius: 1rem;
border: 1px solid var(--msg-border);
word-wrap: break-word;
animation: fadeIn 0.3s ease-in;
}
@keyframes fadeIn {
@@ -55,35 +144,15 @@ main {
/* Own Messages (right-aligned) */
.message.own {
align-self: flex-end;
background-color: var(--msg-own-bg);
border-color: #b8daff;
}
/* Other Messages (left-aligned) */
.message.other {
align-self: flex-start;
background-color: var(--msg-other-bg);
}
/* Message Header */
.message-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.25rem;
font-size: 0.875rem;
}
.message-sender {
font-weight: 600;
color: #0d6efd;
}
.message.own .message-sender {
color: #084298;
}
.message-time {
font-size: 0.75rem;
color: #6c757d;
@@ -190,15 +259,28 @@ main {
/* Responsive Design */
@media (max-width: 768px) {
.message,
.dm-message {
max-width: 85%;
.message-wrapper {
max-width: 90%;
}
.message-header {
.message-avatar {
width: 32px;
height: 32px;
font-size: 0.75rem;
}
.message-avatar.emoji {
font-size: 1.1rem;
}
.message-sender {
font-size: 0.8rem;
}
.dm-message {
max-width: 85%;
}
#messageInput,
#dmMessageInput {
font-size: 0.9rem;
+119 -25
View File
@@ -658,8 +658,8 @@ function displayMessages(messages) {
* Create message DOM element
*/
function createMessageElement(msg) {
const div = document.createElement('div');
div.className = `message ${msg.is_own ? 'own' : 'other'}`;
const wrapper = document.createElement('div');
wrapper.className = `message-wrapper ${msg.is_own ? 'own' : 'other'}`;
const time = formatTime(msg.timestamp);
@@ -671,31 +671,53 @@ function createMessageElement(msg) {
metaInfo += ` | Hops: ${msg.path_len}`;
}
div.innerHTML = `
<div class="message-header">
<span class="message-sender">${escapeHtml(msg.sender)}</span>
<span class="message-time">${time}</span>
</div>
<div class="message-content">${processMessageContent(msg.content)}</div>
${metaInfo ? `<div class="message-meta">${metaInfo}</div>` : ''}
${!msg.is_own ? `
<div class="mt-1 d-flex gap-1">
<button class="btn btn-outline-secondary btn-msg-action" onclick="replyTo('${escapeHtml(msg.sender)}')" title="Reply">
<i class="bi bi-reply"></i>
</button>
<button class="btn btn-outline-secondary btn-msg-action" onclick='quoteTo(${JSON.stringify(msg.sender)}, ${JSON.stringify(msg.content)})' title="Quote">
<i class="bi bi-quote"></i>
</button>
${contactsGeoCache[msg.sender] ? `
<button class="btn btn-outline-primary btn-msg-action" onclick="showContactOnMap('${escapeHtml(msg.sender)}', ${contactsGeoCache[msg.sender].lat}, ${contactsGeoCache[msg.sender].lon})" title="Show on map">
<i class="bi bi-geo-alt"></i>
</button>
` : ''}
if (msg.is_own) {
// Own messages: right-aligned, no avatar
wrapper.innerHTML = `
<div class="message-container">
<div class="message own">
<div class="message-content">${processMessageContent(msg.content)}</div>
</div>
<div class="message-footer own">
<span class="message-time">${time}</span>
</div>
</div>
` : ''}
`;
`;
} else {
// Other messages: left-aligned with avatar
const avatar = generateAvatar(msg.sender);
return div;
wrapper.innerHTML = `
<div class="message-avatar${avatar.isEmoji ? ' emoji' : ''}" style="background-color: ${avatar.color};">
${avatar.content}
</div>
<div class="message-container">
<div class="message-sender-row">
<span class="message-sender">${escapeHtml(msg.sender)}</span>
<span class="message-time">${time}</span>
</div>
<div class="message other">
<div class="message-content">${processMessageContent(msg.content)}</div>
${metaInfo ? `<div class="message-meta">${metaInfo}</div>` : ''}
</div>
<div class="message-actions">
<button class="btn btn-outline-secondary btn-msg-action" onclick="replyTo('${escapeHtml(msg.sender)}')" title="Reply">
<i class="bi bi-reply"></i>
</button>
<button class="btn btn-outline-secondary btn-msg-action" onclick='quoteTo(${JSON.stringify(msg.sender)}, ${JSON.stringify(msg.content)})' title="Quote">
<i class="bi bi-quote"></i>
</button>
${contactsGeoCache[msg.sender] ? `
<button class="btn btn-outline-primary btn-msg-action" onclick="showContactOnMap('${escapeHtml(msg.sender)}', ${contactsGeoCache[msg.sender].lat}, ${contactsGeoCache[msg.sender].lon})" title="Show on map">
<i class="bi bi-geo-alt"></i>
</button>
` : ''}
</div>
</div>
`;
}
return wrapper;
}
/**
@@ -1407,6 +1429,78 @@ function escapeHtml(text) {
return div.innerHTML;
}
// =============================================================================
// Avatar Generation Functions
// =============================================================================
/**
* Generate a consistent color based on string hash
* @param {string} str - Input string (username)
* @returns {string} HSL color string
*/
function getAvatarColor(str) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
// Generate hue from hash (0-360), keep saturation and lightness fixed for readability
const hue = Math.abs(hash) % 360;
return `hsl(${hue}, 65%, 45%)`;
}
/**
* Extract first emoji from a string
* @param {string} str - Input string
* @returns {string|null} First emoji found or null
*/
function extractFirstEmoji(str) {
// Regex to match emojis (including compound emojis with ZWJ sequences)
const emojiRegex = /(\p{Emoji_Presentation}|\p{Emoji}\uFE0F)(\u200D(\p{Emoji_Presentation}|\p{Emoji}\uFE0F))*/u;
const match = str.match(emojiRegex);
return match ? match[0] : null;
}
/**
* Get initials from a username
* @param {string} name - Username
* @returns {string} 1-2 character initials
*/
function getInitials(name) {
// Remove emojis first
const cleanName = name.replace(/(\p{Emoji_Presentation}|\p{Emoji}\uFE0F)(\u200D(\p{Emoji_Presentation}|\p{Emoji}\uFE0F))*/gu, '').trim();
if (!cleanName) return '?';
// Split by common separators (space, underscore, dash)
const parts = cleanName.split(/[\s_\-]+/).filter(p => p.length > 0);
if (parts.length >= 2) {
// Two or more words: use first letter of first two words
return (parts[0][0] + parts[1][0]).toUpperCase();
} else if (parts.length === 1) {
// Single word: use first letter only
return parts[0][0].toUpperCase();
}
return '?';
}
/**
* Generate avatar HTML for a username
* @param {string} name - Username
* @returns {object} { content: string, color: string }
*/
function generateAvatar(name) {
const emoji = extractFirstEmoji(name);
const color = getAvatarColor(name);
if (emoji) {
return { content: emoji, color: color, isEmoji: true };
} else {
return { content: getInitials(name), color: color, isEmoji: false };
}
}
/**
* Load last seen timestamps from server
*/