Add user profile management: display name + avatar upload (Gitea issue #12)

Users can set a display name (shown instead of username in the message
list, room member list, TopBar, and admin Users tab) and upload a real
avatar, replacing the generated color-initial avatars everywhere a user
appears. Avatars are square-cropped and downscaled to 512px, reusing
app/storage.py's upload primitives from image uploads with a new square
option.

Two deliberate divergences from message-image handling, documented in
backend/README.md: the previous avatar file is deleted on replace/remove
(safe since it's strictly one file per user), and avatar serving is not
room-gated and uses a short cache (identity-addressed and mutable, unlike
a message image's permanent content-addressed URL).

Frontend: new ProfileModal reachable from the TopBar account menu;
AuthContext gains updateUser() so a profile change reflects instantly
everywhere without a refetch.
This commit is contained in:
2026-08-14 16:59:56 -06:00
parent c6f90d49fc
commit 8ca3e2e23d
28 changed files with 689 additions and 41 deletions
+11 -6
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react'
import { getRoomImageUrl } from '../api/rooms'
import { useAuth } from '../context/AuthContext'
import { senderColorIndex } from '../lib/messageGrouping'
import { avatarUrlFor, displayNameFor, senderColorIndex } from '../lib/messageGrouping'
import type { ChatMessageEnvelope, Message, RoomMember } from '../types'
import { EmojiPicker } from './EmojiPicker'
import { ImageLightbox } from './ImageLightbox'
@@ -24,8 +24,9 @@ export function MessageList({ roomId, messages, members, onEdit, onReact }: Mess
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null)
const [reactingId, setReactingId] = useState<string | null>(null)
function usernameFor(userId: string): string {
return members.find((m) => m.user_id === userId)?.username ?? 'someone'
function displayNameForUserId(userId: string): string {
const member = members.find((m) => m.user_id === userId)
return member?.display_name || member?.username || 'someone'
}
useEffect(() => {
@@ -59,13 +60,17 @@ export function MessageList({ roomId, messages, members, onEdit, onReact }: Mess
<div key={msg.id} className={`message-row${isGroupStart ? ' message-row-start' : ''}`}>
<div className="message-avatar-slot">
{isGroupStart && (
<UserAvatar username={msg.username} colorIndex={senderColorIndex(msg.username, members)} />
<UserAvatar
username={msg.username}
colorIndex={senderColorIndex(msg.username, members)}
avatarUrl={avatarUrlFor(msg.username, members)}
/>
)}
</div>
<div className="message-content">
{isGroupStart && (
<div className="message-header">
<span className="message-author">{msg.username}</span>
<span className="message-author">{displayNameFor(msg.username, members)}</span>
<span className="message-time">
{new Date(msg.created_at).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}
</span>
@@ -108,7 +113,7 @@ export function MessageList({ roomId, messages, members, onEdit, onReact }: Mess
key={r.emoji}
type="button"
className={`message-reaction-pill${mineReaction ? ' message-reaction-pill-mine' : ''}`}
title={r.user_ids.map(usernameFor).join(', ')}
title={r.user_ids.map(displayNameForUserId).join(', ')}
onClick={() => onReact(msg.id, r.emoji)}
>
<span>{r.emoji}</span>