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:
parent
df59accb81
commit
39aaa96a99
@ -224,6 +224,7 @@
|
||||
mentions: [],
|
||||
is_ai: true,
|
||||
streaming: true,
|
||||
avatar_hash: '',
|
||||
},
|
||||
})
|
||||
this.scrollToBottom()
|
||||
|
||||
@ -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: '',
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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()
|
||||
},
|
||||
|
||||
116
client/src/services/avatar.js
Normal file
116
client/src/services/avatar.js
Normal 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
27
server/Cargo.lock
generated
@ -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",
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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) = ¶ms.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) = ¶ms.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();
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user