feat: add Gravatar avatars with monsterid fallback for user identification

- Add avatar_hash (MD5 of email) to MessagePayload for server-side hash computation
- Create avatar.js with Gravatar URL generation and client-side MD5 implementation
- Show sender names and unique avatars on all messages including own messages
- Use monsterid fallback for users without Gravatar, robot icons reserved for AI
- LEFT JOIN users table in message history queries for avatar hash lookup

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jason Tudisco 2026-03-08 06:41:48 -06:00
parent df59accb81
commit 39aaa96a99
10 changed files with 239 additions and 43 deletions

View File

@ -224,6 +224,7 @@
mentions: [],
is_ai: true,
streaming: true,
avatar_hash: '',
},
})
this.scrollToBottom()

View File

@ -54,7 +54,15 @@
<span class="member-role ai-role">AI</span>
</div>
<div each={member in props.room?.members} key={member.id} class="member-item">
<div class="member-avatar">{member.display_name?.charAt(0).toUpperCase()}</div>
<div class="member-avatar">
<img src={avatarFromEmail(member.email, 28)}
alt={member.display_name}
width="28"
height="28"
class="avatar-img"
loading="lazy"
/>
</div>
<span class="member-name">{member.display_name}</span>
<span if={member.id === props.room?.created_by} class="member-role">Owner</span>
</div>
@ -222,6 +230,14 @@
font-size: var(--text-xs);
color: var(--text-secondary);
flex-shrink: 0;
overflow: hidden;
}
.avatar-img {
width: 100%;
height: 100%;
border-radius: var(--radius-full);
object-fit: cover;
}
.member-item .ai-avatar {
@ -413,8 +429,11 @@
<script>
import { ws } from '../services/websocket.js'
import { avatarFromEmail as _avatarFromEmail } from '../services/avatar.js'
export default {
avatarFromEmail: _avatarFromEmail,
state: {
inputValue: '',
typingDisplay: '',

View File

@ -34,7 +34,13 @@
<div class="sidebar-footer">
<div class="user-info">
<div class="user-avatar">
{props.user?.display_name?.charAt(0).toUpperCase()}
<img src={avatarFromEmail(props.user?.email)}
alt={props.user?.display_name}
width="32"
height="32"
class="avatar-img"
loading="lazy"
/>
</div>
<span class="user-name">{props.user?.display_name}</span>
</div>
@ -176,14 +182,21 @@
width: 32px;
height: 32px;
border-radius: var(--radius-full);
background: var(--accent);
color: white;
background: var(--bg-elevated);
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: var(--text-sm);
flex-shrink: 0;
overflow: hidden;
}
.avatar-img {
width: 100%;
height: 100%;
border-radius: var(--radius-full);
object-fit: cover;
}
.user-name {
@ -195,6 +208,10 @@
</style>
<script>
export default {}
import { avatarFromEmail as _avatarFromEmail } from '../services/avatar.js'
export default {
avatarFromEmail: _avatarFromEmail,
}
</script>
</chat-sidebar>

View File

@ -1,6 +1,6 @@
<message-bubble>
<div class={'message ' + (props.message?.is_ai ? 'ai-message' : '') + (props.isOwn ? ' own-message' : '')}>
<div if={!props.isOwn} class="message-avatar-col">
<div class="message-avatar-col">
<div class={'message-avatar ' + (props.message?.is_ai ? 'ai-avatar' : '')}>
<svg if={props.message?.is_ai} width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="4" y="8" width="16" height="12" rx="2"/>
@ -9,17 +9,21 @@
<circle cx="9" cy="14" r="1.5" fill="currentColor"/>
<circle cx="15" cy="14" r="1.5" fill="currentColor"/>
</svg>
<template if={!props.message?.is_ai}>{props.message?.sender_name?.charAt(0).toUpperCase()}</template>
<img if={!props.message?.is_ai}
src={avatarFromHash(props.message?.avatar_hash)}
alt={props.message?.sender_name}
width="32"
height="32"
class="avatar-img"
loading="lazy"
/>
</div>
</div>
<div class="message-body">
<div if={!props.isOwn} class="message-header">
<div class={'message-header ' + (props.isOwn ? 'own' : '')}>
<span class="sender-name">{props.message?.sender_name}</span>
<span class="message-time">{formatTime(props.message?.created_at)}</span>
</div>
<div if={props.isOwn} class="message-header own">
<span class="message-time">{formatTime(props.message?.created_at)}</span>
</div>
<div if={hasToolResults()} class="tool-results-section">
<div each={tr in getToolResults()} class="tool-result-item">
<button class="tool-result-toggle" onclick={toggleToolResult}>
@ -91,6 +95,14 @@
font-weight: 600;
font-size: var(--text-xs);
color: var(--text-secondary);
overflow: hidden;
}
.avatar-img {
width: 100%;
height: 100%;
border-radius: var(--radius-full);
object-fit: cover;
}
.ai-avatar {
@ -111,6 +123,7 @@
.message-header.own {
justify-content: flex-end;
flex-direction: row-reverse;
}
.sender-name {
@ -308,8 +321,11 @@
<script>
import { renderMarkdown } from '../services/markdown.js'
import { avatarFromHash as _avatarFromHash } from '../services/avatar.js'
export default {
avatarFromHash: _avatarFromHash,
onMounted() {
this.renderContent()
},

View File

@ -0,0 +1,116 @@
/**
* Gravatar avatar utilities.
* Uses Gravatar with "monsterid" fallback real photos for users who have
* a Gravatar account, unique monster icons for everyone else.
*/
/**
* Get avatar URL from a pre-computed MD5 hash (used for messages).
* @param {string} hash - MD5 hash of the user's email (from server)
* @param {number} [size=64] - Pixel size
* @returns {string} Gravatar URL
*/
export function avatarFromHash(hash, size = 64) {
if (!hash) return `https://www.gravatar.com/avatar/?d=monsterid&s=${size}`
return `https://www.gravatar.com/avatar/${hash}?d=monsterid&s=${size}`
}
/**
* Get avatar URL from an email address (used for member list / sidebar).
* Computes MD5 client-side.
* @param {string} email - User's email
* @param {number} [size=64] - Pixel size
* @returns {string} Gravatar URL
*/
export function avatarFromEmail(email, size = 64) {
if (!email) return `https://www.gravatar.com/avatar/?d=monsterid&s=${size}`
const hash = md5(email.trim().toLowerCase())
return `https://www.gravatar.com/avatar/${hash}?d=monsterid&s=${size}`
}
// ── Compact MD5 implementation ──────────────────────────────────────────
// Based on Joseph Myers' implementation (public domain)
function md5(string) {
function md5cycle(x, k) {
let a = x[0], b = x[1], c = x[2], d = x[3]
a = ff(a, b, c, d, k[0], 7, -680876936); d = ff(d, a, b, c, k[1], 12, -389564586)
c = ff(c, d, a, b, k[2], 17, 606105819); b = ff(b, c, d, a, k[3], 22, -1044525330)
a = ff(a, b, c, d, k[4], 7, -176418897); d = ff(d, a, b, c, k[5], 12, 1200080426)
c = ff(c, d, a, b, k[6], 17, -1473231341); b = ff(b, c, d, a, k[7], 22, -45705983)
a = ff(a, b, c, d, k[8], 7, 1770035416); d = ff(d, a, b, c, k[9], 12, -1958414417)
c = ff(c, d, a, b, k[10], 17, -42063); b = ff(b, c, d, a, k[11], 22, -1990404162)
a = ff(a, b, c, d, k[12], 7, 1804603682); d = ff(d, a, b, c, k[13], 12, -40341101)
c = ff(c, d, a, b, k[14], 17, -1502002290); b = ff(b, c, d, a, k[15], 22, 1236535329)
a = gg(a, b, c, d, k[1], 5, -165796510); d = gg(d, a, b, c, k[6], 9, -1069501632)
c = gg(c, d, a, b, k[11], 14, 643717713); b = gg(b, c, d, a, k[0], 20, -373897302)
a = gg(a, b, c, d, k[5], 5, -701558691); d = gg(d, a, b, c, k[10], 9, 38016083)
c = gg(c, d, a, b, k[15], 14, -660478335); b = gg(b, c, d, a, k[4], 20, -405537848)
a = gg(a, b, c, d, k[9], 5, 568446438); d = gg(d, a, b, c, k[14], 9, -1019803690)
c = gg(c, d, a, b, k[3], 14, -187363961); b = gg(b, c, d, a, k[8], 20, 1163531501)
a = gg(a, b, c, d, k[13], 5, -1444681467); d = gg(d, a, b, c, k[2], 9, -51403784)
c = gg(c, d, a, b, k[7], 14, 1735328473); b = gg(b, c, d, a, k[12], 20, -1926607734)
a = hh(a, b, c, d, k[5], 4, -378558); d = hh(d, a, b, c, k[8], 11, -2022574463)
c = hh(c, d, a, b, k[11], 16, 1839030562); b = hh(b, c, d, a, k[14], 23, -35309556)
a = hh(a, b, c, d, k[1], 4, -1530992060); d = hh(d, a, b, c, k[4], 11, 1272893353)
c = hh(c, d, a, b, k[7], 16, -155497632); b = hh(b, c, d, a, k[10], 23, -1094730640)
a = hh(a, b, c, d, k[13], 4, 681279174); d = hh(d, a, b, c, k[0], 11, -358537222)
c = hh(c, d, a, b, k[3], 16, -722521979); b = hh(b, c, d, a, k[6], 23, 76029189)
a = hh(a, b, c, d, k[9], 4, -640364487); d = hh(d, a, b, c, k[12], 11, -421815835)
c = hh(c, d, a, b, k[15], 16, 530742520); b = hh(b, c, d, a, k[2], 23, -995338651)
a = ii(a, b, c, d, k[0], 6, -198630844); d = ii(d, a, b, c, k[7], 10, 1126891415)
c = ii(c, d, a, b, k[14], 15, -1416354905); b = ii(b, c, d, a, k[5], 21, -57434055)
a = ii(a, b, c, d, k[12], 6, 1700485571); d = ii(d, a, b, c, k[3], 10, -1894986606)
c = ii(c, d, a, b, k[10], 15, -1051523); b = ii(b, c, d, a, k[1], 21, -2054922799)
a = ii(a, b, c, d, k[8], 6, 1873313359); d = ii(d, a, b, c, k[15], 10, -30611744)
c = ii(c, d, a, b, k[6], 15, -1560198380); b = ii(b, c, d, a, k[13], 21, 1309151649)
a = ii(a, b, c, d, k[4], 6, -145523070); d = ii(d, a, b, c, k[11], 10, -1120210379)
c = ii(c, d, a, b, k[2], 15, 718787259); b = ii(b, c, d, a, k[9], 21, -343485551)
x[0] = add32(a, x[0]); x[1] = add32(b, x[1])
x[2] = add32(c, x[2]); x[3] = add32(d, x[3])
}
function cmn(q, a, b, x, s, t) {
a = add32(add32(a, q), add32(x, t))
return add32((a << s) | (a >>> (32 - s)), b)
}
function ff(a, b, c, d, x, s, t) { return cmn((b & c) | ((~b) & d), a, b, x, s, t) }
function gg(a, b, c, d, x, s, t) { return cmn((b & d) | (c & (~d)), a, b, x, s, t) }
function hh(a, b, c, d, x, s, t) { return cmn(b ^ c ^ d, a, b, x, s, t) }
function ii(a, b, c, d, x, s, t) { return cmn(c ^ (b | (~d)), a, b, x, s, t) }
function add32(a, b) { return (a + b) & 0xFFFFFFFF }
const n = string.length
let state = [1732584193, -271733879, -1732584194, 271733878]
let i
for (i = 64; i <= n; i += 64) {
md5cycle(state, md5blk(string.substring(i - 64, i)))
}
string = string.substring(i - 64)
const tail = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
for (i = 0; i < string.length; i++)
tail[i >> 2] |= string.charCodeAt(i) << ((i % 4) << 3)
tail[i >> 2] |= 0x80 << ((i % 4) << 3)
if (i > 55) {
md5cycle(state, tail)
for (i = 0; i < 16; i++) tail[i] = 0
}
tail[14] = n * 8
md5cycle(state, tail)
return hex(state)
function md5blk(s) {
const md5blks = []
for (let i = 0; i < 64; i += 4)
md5blks[i >> 2] = s.charCodeAt(i) + (s.charCodeAt(i + 1) << 8) +
(s.charCodeAt(i + 2) << 16) + (s.charCodeAt(i + 3) << 24)
return md5blks
}
function hex(x) {
const hexChars = '0123456789abcdef'
let s = ''
for (let i = 0; i < 4; i++)
for (let j = 0; j < 4; j++)
s += hexChars[(x[i] >> (j * 8 + 4)) & 0x0F] + hexChars[(x[i] >> (j * 8)) & 0x0F]
return s
}
}

27
server/Cargo.lock generated
View File

@ -480,9 +480,9 @@ dependencies = [
[[package]]
name = "deranged"
version = "0.5.8"
version = "0.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4"
dependencies = [
"powerfmt",
]
@ -855,6 +855,7 @@ dependencies = [
"dotenvy",
"futures",
"jsonwebtoken",
"md-5",
"rand",
"reqwest",
"scraper",
@ -1597,9 +1598,9 @@ dependencies = [
[[package]]
name = "num-conv"
version = "0.2.0"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
[[package]]
name = "num-integer"
@ -2331,9 +2332,9 @@ checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
[[package]]
name = "simple_asn1"
version = "0.6.4"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb"
dependencies = [
"num-bigint",
"num-traits",
@ -2786,30 +2787,30 @@ dependencies = [
[[package]]
name = "time"
version = "0.3.47"
version = "0.3.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
checksum = "35e7868883861bd0e56d9ac6efcaaca0d6d5d82a2a7ec8209ff492c07cf37b21"
dependencies = [
"deranged",
"itoa",
"num-conv",
"powerfmt",
"serde_core",
"serde",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.8"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3"
[[package]]
name = "time-macros"
version = "0.2.27"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
checksum = "2834e6017e3e5e4b9834939793b282bc03b37a3336245fa820e35e233e2a85de"
dependencies = [
"num-conv",
"time-core",

View File

@ -24,3 +24,4 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
rand = "0.8"
async-trait = "0.1"
scraper = "0.22"
md-5 = "0.10"

View File

@ -8,7 +8,7 @@ use uuid::Uuid;
use crate::{
middleware::auth::AuthUser,
models::{CreateRoomRequest, Message, MessagePayload, PaginationParams, Room, RoomResponse, UserPublic},
models::{CreateRoomRequest, MessagePayload, PaginationParams, Room, RoomResponse, UserPublic},
AppState,
};
@ -177,9 +177,12 @@ pub async fn get_messages(
return Err((StatusCode::FORBIDDEN, "Not a member of this room".into()));
}
let messages = if let Some(before) = &params.before {
sqlx::query_as::<_, Message>(
"SELECT * FROM messages WHERE room_id = ? AND created_at < ? ORDER BY created_at DESC LIMIT ?",
// Query messages with user email via LEFT JOIN for Gravatar hash
let rows = if let Some(before) = &params.before {
sqlx::query_as::<_, (String, String, String, String, String, String, bool, String, Option<String>, Option<String>)>(
"SELECT m.id, m.room_id, m.sender_id, m.sender_name, m.content, m.mentions, m.is_ai, m.created_at, m.ai_meta, u.email \
FROM messages m LEFT JOIN users u ON m.sender_id = u.id \
WHERE m.room_id = ? AND m.created_at < ? ORDER BY m.created_at DESC LIMIT ?",
)
.bind(&room_id)
.bind(before)
@ -187,8 +190,10 @@ pub async fn get_messages(
.fetch_all(&state.db)
.await
} else {
sqlx::query_as::<_, Message>(
"SELECT * FROM messages WHERE room_id = ? ORDER BY created_at DESC LIMIT ?",
sqlx::query_as::<_, (String, String, String, String, String, String, bool, String, Option<String>, Option<String>)>(
"SELECT m.id, m.room_id, m.sender_id, m.sender_name, m.content, m.mentions, m.is_ai, m.created_at, m.ai_meta, u.email \
FROM messages m LEFT JOIN users u ON m.sender_id = u.id \
WHERE m.room_id = ? ORDER BY m.created_at DESC LIMIT ?",
)
.bind(&room_id)
.bind(params.limit)
@ -197,23 +202,27 @@ pub async fn get_messages(
}
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let payloads: Vec<MessagePayload> = messages
let payloads: Vec<MessagePayload> = rows
.into_iter()
.rev()
.map(|m| {
let ai_meta = m.ai_meta
.map(|(id, room_id, sender_id, sender_name, content, mentions, is_ai, created_at, ai_meta_str, email)| {
let ai_meta = ai_meta_str
.as_deref()
.and_then(|s| serde_json::from_str::<crate::models::AiMeta>(s).ok());
let avatar_hash = email
.map(|e| crate::models::gravatar_hash(&e))
.unwrap_or_default();
MessagePayload {
id: m.id,
room_id: m.room_id,
sender_id: m.sender_id,
sender_name: m.sender_name,
content: m.content,
mentions: serde_json::from_str(&m.mentions).unwrap_or_default(),
is_ai: m.is_ai,
created_at: m.created_at,
id,
room_id,
sender_id,
sender_name,
content,
mentions: serde_json::from_str(&mentions).unwrap_or_default(),
is_ai,
created_at,
ai_meta,
avatar_hash,
}
})
.collect();

View File

@ -37,10 +37,10 @@ pub async fn ws_handler(
}
};
ws.on_upgrade(move |socket| handle_socket(socket, state, claims.sub, claims.display_name))
ws.on_upgrade(move |socket| handle_socket(socket, state, claims.sub, claims.display_name, claims.email))
}
async fn handle_socket(socket: WebSocket, state: Arc<AppState>, user_id: String, display_name: String) {
async fn handle_socket(socket: WebSocket, state: Arc<AppState>, user_id: String, display_name: String, email: String) {
let (mut ws_tx, mut ws_rx) = socket.split();
let mut broadcast_rx = state.tx.subscribe();
@ -78,6 +78,7 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, user_id: String,
let state_clone = state.clone();
let user_id_clone = user_id.clone();
let display_name_clone = display_name.clone();
let email_clone = email.clone();
let rooms_clone2 = subscribed_rooms.clone();
// Task: receive messages from client
@ -127,6 +128,7 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, user_id: String,
&state_clone,
&user_id_clone,
&display_name_clone,
&email_clone,
&room_id,
&content,
&mentions,
@ -150,6 +152,7 @@ async fn handle_send_message(
state: &Arc<AppState>,
user_id: &str,
display_name: &str,
email: &str,
room_id: &str,
content: &str,
mentions: &[String],
@ -183,6 +186,8 @@ async fn handle_send_message(
is_ai: false,
created_at: now,
ai_meta: None,
avatar_hash: crate::models::gravatar_hash(email),
};
let _ = state.tx.send(BroadcastEvent {
@ -408,6 +413,7 @@ async fn handle_send_message(
is_ai: true,
created_at: ai_now,
ai_meta,
avatar_hash: String::new(),
};
let _ = state.tx.send(BroadcastEvent {

View File

@ -205,6 +205,16 @@ pub struct MessagePayload {
pub created_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub ai_meta: Option<AiMeta>,
#[serde(default)]
pub avatar_hash: String,
}
/// Compute Gravatar-compatible MD5 hash from an email address.
pub fn gravatar_hash(email: &str) -> String {
use md5::{Md5, Digest};
let normalized = email.trim().to_lowercase();
let result = Md5::digest(normalized.as_bytes());
format!("{:x}", result)
}
#[derive(Debug, Clone, Serialize, Deserialize)]