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>
+18
View File
@@ -182,3 +182,21 @@
font-size: 0.76rem;
color: var(--ds-muted);
}
.profile-modal-avatar-row {
display: flex;
align-items: center;
gap: var(--sp-4);
margin-bottom: var(--sp-4);
}
.profile-modal-avatar-actions {
display: flex;
flex-direction: column;
gap: var(--sp-2);
align-items: flex-start;
}
.modal-hidden-file-input {
display: none;
}
+129
View File
@@ -0,0 +1,129 @@
import { useRef, useState, type ChangeEvent, type FormEvent } from 'react'
import { removeAvatar, updateProfile, uploadAvatar } from '../api/auth'
import { ApiError } from '../api/client'
import { getUserAvatarUrl } from '../api/users'
import { useAuth } from '../context/AuthContext'
import { hashIndex } from '../lib/avatar'
import { UserAvatar } from './UserAvatar'
import './Modal.css'
interface ProfileModalProps {
onClose: () => void
}
export function ProfileModal({ onClose }: ProfileModalProps) {
const { user, updateUser } = useAuth()
const [displayName, setDisplayName] = useState(user?.display_name ?? '')
const [error, setError] = useState<string | null>(null)
const [savingName, setSavingName] = useState(false)
const [uploadingAvatar, setUploadingAvatar] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
if (!user) return null
async function handleSaveName(e: FormEvent) {
e.preventDefault()
setSavingName(true)
setError(null)
try {
const updated = await updateProfile(displayName.trim() || null)
updateUser(updated)
} catch (err) {
setError(err instanceof ApiError ? err.message : String(err))
} finally {
setSavingName(false)
}
}
async function handleFileSelected(e: ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
e.target.value = ''
if (!file) return
setUploadingAvatar(true)
setError(null)
try {
const updated = await uploadAvatar(file)
updateUser(updated)
} catch (err) {
setError(err instanceof ApiError ? err.message : String(err))
} finally {
setUploadingAvatar(false)
}
}
async function handleRemoveAvatar() {
setError(null)
try {
const updated = await removeAvatar()
updateUser(updated)
} catch (err) {
setError(err instanceof ApiError ? err.message : String(err))
}
}
const avatarUrl = user.avatar_filename ? getUserAvatarUrl(user.id, user.avatar_filename) : null
return (
<div className="modal-scrim" onClick={onClose}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2>Profile settings</h2>
<button type="button" className="modal-close" onClick={onClose} aria-label="Close">
&times;
</button>
</div>
<div className="profile-modal-avatar-row">
<UserAvatar
username={user.username}
colorIndex={hashIndex(user.username)}
size={64}
avatarUrl={avatarUrl}
/>
<div className="profile-modal-avatar-actions">
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
className="modal-hidden-file-input"
onChange={handleFileSelected}
/>
<button
type="button"
className="btn-secondary"
onClick={() => fileInputRef.current?.click()}
disabled={uploadingAvatar}
>
{uploadingAvatar ? 'Uploading…' : 'Upload photo'}
</button>
{avatarUrl && (
<button type="button" className="btn-secondary" onClick={handleRemoveAvatar}>
Remove photo
</button>
)}
</div>
</div>
<form onSubmit={handleSaveName}>
<div className="modal-field-label">Display name</div>
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder={user.username}
maxLength={50}
/>
{error && <p className="modal-error">{error}</p>}
<div className="modal-actions">
<button type="button" className="btn-secondary" onClick={onClose}>
Close
</button>
<button type="submit" className="btn-primary" disabled={savingName}>
Save
</button>
</div>
</form>
</div>
</div>
)
}
+8 -2
View File
@@ -1,6 +1,7 @@
import { useEffect, useState, type FormEvent } from 'react'
import { ApiError } from '../api/client'
import { createInvite, listRoomInvites, revokeInvite } from '../api/invites'
import { getUserAvatarUrl } from '../api/users'
import {
changeMemberRole,
deleteRoom,
@@ -230,8 +231,13 @@ export function RoomInfoPanel({
<div className="room-info-label">Members</div>
{members.map((m, i) => (
<div key={m.user_id} className="room-info-member-row">
<UserAvatar username={m.username} colorIndex={i} size={24} />
<span className="room-info-member-name">{m.username}</span>
<UserAvatar
username={m.username}
colorIndex={i}
size={24}
avatarUrl={m.avatar_filename ? getUserAvatarUrl(m.user_id, m.avatar_filename) : null}
/>
<span className="room-info-member-name">{m.display_name || m.username}</span>
<span className={`role-badge role-badge-${m.role}`}>{m.role}</span>
{myRole === 'owner' && m.user_id !== user?.id && (
<div className="room-info-member-actions">
+3 -8
View File
@@ -40,16 +40,11 @@
.top-bar-avatar {
width: 30px;
height: 30px;
border-radius: var(--radius-pill);
background: var(--ds-accent-3);
padding: 0;
border: none;
color: var(--ds-text);
font-size: 0.78rem;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
cursor: pointer;
display: flex;
}
.top-bar-menu-scrim {
+23 -3
View File
@@ -1,15 +1,19 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import logo from '../assets/logo.png'
import { getUserAvatarUrl } from '../api/users'
import { useAuth } from '../context/AuthContext'
import { initials } from '../lib/avatar'
import { hashIndex } from '../lib/avatar'
import { getPushSubscriptionStatus, isPushSupported, subscribeToPush, unsubscribeFromPush } from '../lib/push'
import { ProfileModal } from './ProfileModal'
import { UserAvatar } from './UserAvatar'
import './TopBar.css'
export function TopBar() {
const { user, logout } = useAuth()
const navigate = useNavigate()
const [menuOpen, setMenuOpen] = useState(false)
const [profileModalOpen, setProfileModalOpen] = useState(false)
const [pushSubscribed, setPushSubscribed] = useState(false)
const [pushBusy, setPushBusy] = useState(false)
const [pushError, setPushError] = useState<string | null>(null)
@@ -53,13 +57,28 @@ export function TopBar() {
aria-expanded={menuOpen}
aria-label="Account menu"
>
{initials(user.username)}
<UserAvatar
username={user.username}
colorIndex={hashIndex(user.username)}
size={30}
avatarUrl={user.avatar_filename ? getUserAvatarUrl(user.id, user.avatar_filename) : null}
/>
</button>
{menuOpen && (
<>
<div className="top-bar-menu-scrim" onClick={() => setMenuOpen(false)} />
<div className="top-bar-menu" role="menu">
<div className="top-bar-menu-username">{user.username}</div>
<div className="top-bar-menu-username">{user.display_name || user.username}</div>
<button
type="button"
role="menuitem"
onClick={() => {
setMenuOpen(false)
setProfileModalOpen(true)
}}
>
Profile settings
</button>
{user.is_site_admin && (
<button
type="button"
@@ -90,6 +109,7 @@ export function TopBar() {
</>
)}
</div>
{profileModalOpen && <ProfileModal onClose={() => setProfileModalOpen(false)} />}
</header>
)
}
+4
View File
@@ -8,3 +8,7 @@
color: var(--ds-text);
font-size: 0.68rem;
}
.user-avatar-img {
object-fit: cover;
}
+13 -1
View File
@@ -5,9 +5,21 @@ interface UserAvatarProps {
username: string
colorIndex: number
size?: number
avatarUrl?: string | null
}
export function UserAvatar({ username, colorIndex, size = 28 }: UserAvatarProps) {
export function UserAvatar({ username, colorIndex, size = 28, avatarUrl }: UserAvatarProps) {
if (avatarUrl) {
return (
<img
src={avatarUrl}
alt=""
className="user-avatar user-avatar-img"
style={{ width: size, height: size }}
/>
)
}
return (
<div
className="user-avatar"