import { useEffect, useRef, useState, type ChangeEvent, type FormEvent } from 'react' import { changePassword, listSessions, me, removeAvatar, revokeSession, updateEmojiScale, updateProfile, updateTextScale, updateTheme, uploadAvatar, } from '../api/auth' import { ApiError } from '../api/client' import { activateCustomTheme, createCustomTheme, deleteCustomTheme, listCustomThemes, updateCustomTheme, } from '../api/customThemes' import { getUserAvatarUrl } from '../api/users' import { useAuth } from '../context/AuthContext' import { hashIndex } from '../lib/avatar' import { applyTextScale, applyTheme, DEFAULT_CUSTOM_COLORS } from '../lib/theme' import type { CustomTheme, CustomThemeColors, EmojiScale, TextScale, UserSession } from '../types' import { ThemeBuilderModal } from './ThemeBuilderModal' import { UserAvatar } from './UserAvatar' import './Modal.css' const THEME_OPTIONS: { name: 'dark' | 'light' | 'midnight' | 'sunset'; label: string }[] = [ { name: 'dark', label: 'Dark' }, { name: 'light', label: 'Light' }, { name: 'midnight', label: 'Midnight' }, { name: 'sunset', label: 'Sunset' }, ] // #71: the "Aa" preview scales with each option's own size, the standard // way a text-size picker shows what it does without a separate demo area. const TEXT_SCALE_OPTIONS: { name: TextScale; label: string; previewSize: string }[] = [ { name: 'small', label: 'Small', previewSize: '0.8rem' }, { name: 'normal', label: 'Normal', previewSize: '1rem' }, { name: 'large', label: 'Large', previewSize: '1.25rem' }, { name: 'xlarge', label: 'Extra large', previewSize: '1.5rem' }, ] // #71: independent of text size -- only scales emoji rendered in message // text (see MessageContent.tsx's --emoji-scale). The preview uses an // actual emoji so it demonstrates itself the same way the text-size // options do with "Aa". const EMOJI_SCALE_OPTIONS: { name: EmojiScale; label: string; previewSize: string }[] = [ { name: 'small', label: 'Small', previewSize: '1rem' }, { name: 'normal', label: 'Normal', previewSize: '1.25rem' }, { name: 'large', label: 'Large', previewSize: '1.6rem' }, { name: 'xlarge', label: 'Extra large', previewSize: '2rem' }, ] const CUSTOM_COLOR_FIELDS: { key: keyof Omit; label: string }[] = [ { key: 'void', label: 'Background' }, { key: 'void_2', label: 'Sidebar background' }, { key: 'surface', label: 'Surface' }, { key: 'surface_2', label: 'Surface (secondary)' }, { key: 'border', label: 'Border' }, { key: 'text', label: 'Text' }, { key: 'muted', label: 'Muted text' }, { key: 'accent', label: 'Accent' }, { key: 'accent_2', label: 'Accent (secondary)' }, { key: 'accent_3', label: 'Accent (tertiary)' }, { key: 'highlight', label: 'Highlight' }, { key: 'danger', label: 'Danger' }, ] interface ProfileModalProps { onClose: () => void } export function ProfileModal({ onClose }: ProfileModalProps) { const { user, updateUser, logout } = useAuth() const [displayName, setDisplayName] = useState(user?.display_name ?? '') const [error, setError] = useState(null) const [savingName, setSavingName] = useState(false) const [uploadingAvatar, setUploadingAvatar] = useState(false) const fileInputRef = useRef(null) const [themeError, setThemeError] = useState(null) const [textScaleError, setTextScaleError] = useState(null) const [emojiScaleError, setEmojiScaleError] = useState(null) const [customThemes, setCustomThemes] = useState([]) const [editingThemeId, setEditingThemeId] = useState(null) const [editNameDraft, setEditNameDraft] = useState('') const [editColorsDraft, setEditColorsDraft] = useState(DEFAULT_CUSTOM_COLORS) const [savingThemeEdit, setSavingThemeEdit] = useState(false) const [highlightedField, setHighlightedField] = useState(null) const [currentPassword, setCurrentPassword] = useState('') const [newPassword, setNewPassword] = useState('') const [confirmPassword, setConfirmPassword] = useState('') const [passwordError, setPasswordError] = useState(null) const [passwordSuccess, setPasswordSuccess] = useState(false) const [savingPassword, setSavingPassword] = useState(false) const [sessions, setSessions] = useState([]) const [sessionsError, setSessionsError] = useState(null) const [revokingSessionId, setRevokingSessionId] = useState(null) useEffect(() => { listCustomThemes() .then(setCustomThemes) .catch(() => { // Non-critical -- the saved-themes list just stays empty; presets // and everything else in this modal still work fine. }) listSessions() .then(setSessions) .catch(() => { // Same non-critical treatment -- an empty list just means this // section renders no rows rather than failing the whole modal. }) }, []) 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) handleClose() } catch (err) { setError(err instanceof ApiError ? err.message : String(err)) setSavingName(false) } } async function handleFileSelected(e: ChangeEvent) { 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)) } } async function handleSelectPreset(theme: 'dark' | 'light' | 'midnight' | 'sunset') { // Instant visual feedback, then persist -- mirrors avatar upload's // apply-immediately pattern rather than requiring a separate Save. applyTheme(theme, null) setThemeError(null) try { const updated = await updateTheme(theme) updateUser(updated) } catch (err) { applyTheme(user?.theme ?? 'dark', user?.active_custom_theme?.colors ?? null) setThemeError(err instanceof ApiError ? err.message : String(err)) } } async function handleSelectTextScale(scale: TextScale) { // Same instant-apply-then-persist pattern as handleSelectPreset above. applyTextScale(scale) setTextScaleError(null) try { const updated = await updateTextScale(scale) updateUser(updated) } catch (err) { applyTextScale(user?.text_scale ?? null) setTextScaleError(err instanceof ApiError ? err.message : String(err)) } } async function handleSelectEmojiScale(scale: EmojiScale) { // No instant-apply DOM mutation here (unlike theme/text scale) -- it's // just a value MessageContent reads from `user` on its next render, so // persisting and updating that is the whole job. setEmojiScaleError(null) try { const updated = await updateEmojiScale(scale) updateUser(updated) } catch (err) { setEmojiScaleError(err instanceof ApiError ? err.message : String(err)) } } async function handleActivateCustomTheme(theme: CustomTheme) { applyTheme('custom', theme.colors) setThemeError(null) try { const updated = await activateCustomTheme(theme.id) updateUser(updated) } catch (err) { applyTheme(user?.theme ?? 'dark', user?.active_custom_theme?.colors ?? null) setThemeError(err instanceof ApiError ? err.message : String(err)) } } async function handleCreateCustomTheme() { setThemeError(null) try { const created = await createCustomTheme('New theme', DEFAULT_CUSTOM_COLORS) setCustomThemes((prev) => [...prev, created]) await handleActivateCustomTheme(created) openEditor(created) } catch (err) { setThemeError(err instanceof ApiError ? err.message : String(err)) } } function openEditor(theme: CustomTheme) { setEditingThemeId(theme.id) setEditNameDraft(theme.name) setEditColorsDraft(theme.colors) setThemeError(null) setHighlightedField(null) } function closeEditor() { // Only ever live-previewed on screen if this theme was already the // active one (see handleEditColorChange) -- revert that preview back // to whatever's actually persisted if it was never saved. if (editingThemeId && user?.active_custom_theme?.id === editingThemeId) { applyTheme(user.theme, user.active_custom_theme.colors) } setEditingThemeId(null) setHighlightedField(null) } function handleEditColorChange(key: keyof CustomThemeColors, value: string) { const next = { ...editColorsDraft, [key]: value } setEditColorsDraft(next) // Only reflect on the whole page live if the theme being edited is // already the active one -- editing a theme you're not currently using // shouldn't hijack what's on screen right now. if (editingThemeId && user?.active_custom_theme?.id === editingThemeId) { applyTheme('custom', next) } } async function handleSaveThemeEdit() { if (!editingThemeId || !user) return setSavingThemeEdit(true) setThemeError(null) try { const updated = await updateCustomTheme(editingThemeId, { name: editNameDraft.trim() || 'Untitled', colors: editColorsDraft, }) setCustomThemes((prev) => prev.map((t) => (t.id === updated.id ? updated : t))) if (user.active_custom_theme?.id === updated.id) { updateUser({ ...user, active_custom_theme: updated }) } setEditingThemeId(null) } catch (err) { setThemeError(err instanceof ApiError ? err.message : String(err)) } finally { setSavingThemeEdit(false) } } async function handleDeleteCustomTheme(theme: CustomTheme) { if (!confirm(`Delete "${theme.name}"? This can't be undone.`) || !user) return setThemeError(null) try { await deleteCustomTheme(theme.id) setCustomThemes((prev) => prev.filter((t) => t.id !== theme.id)) if (editingThemeId === theme.id) setEditingThemeId(null) if (user.active_custom_theme?.id === theme.id) { // The backend fell back to a preset for us -- pick that up rather // than guessing what it chose. const refreshed = await me() updateUser(refreshed) applyTheme(refreshed.theme, refreshed.active_custom_theme?.colors ?? null) } } catch (err) { setThemeError(err instanceof ApiError ? err.message : String(err)) } } function handleClose() { if (editingThemeId) closeEditor() onClose() } async function handleChangePassword(e: FormEvent) { e.preventDefault() setPasswordError(null) setPasswordSuccess(false) if (newPassword !== confirmPassword) { setPasswordError("New passwords don't match") return } setSavingPassword(true) try { await changePassword(currentPassword, newPassword) setCurrentPassword('') setNewPassword('') setConfirmPassword('') setPasswordSuccess(true) } catch (err) { setPasswordError(err instanceof ApiError ? err.message : String(err)) } finally { setSavingPassword(false) } } async function handleRevokeSession(session: UserSession) { setSessionsError(null) setRevokingSessionId(session.id) try { if (session.is_current) { // Revoking your own current session is really just "sign out" -- // go through the normal logout path so local auth state (and the // rest of the app) clears immediately, instead of waiting for the // next request to organically 401. await logout() return } await revokeSession(session.id) setSessions((prev) => prev.filter((s) => s.id !== session.id)) } catch (err) { setSessionsError(err instanceof ApiError ? err.message : String(err)) } finally { setRevokingSessionId(null) } } const avatarUrl = user.avatar_filename ? getUserAvatarUrl(user.id, user.avatar_filename) : null const editingTheme = customThemes.find((t) => t.id === editingThemeId) ?? null return (
e.stopPropagation()}>

Profile settings

{avatarUrl && ( )}

Theme
{THEME_OPTIONS.map((option) => ( ))}
{themeError &&

{themeError}

}
Text size
{TEXT_SCALE_OPTIONS.map((option) => ( ))}
{textScaleError &&

{textScaleError}

}
Emoji size
{EMOJI_SCALE_OPTIONS.map((option) => ( ))}
{emojiScaleError &&

{emojiScaleError}

}
My custom themes
{customThemes.map((theme) => { const active = user.theme === 'custom' && user.active_custom_theme?.id === theme.id return (
) })}
{editingTheme && ( )}
Display name
setDisplayName(e.target.value)} placeholder={user.username} maxLength={50} /> {error &&

{error}

}

Change password
setCurrentPassword(e.target.value)} placeholder="Current password" autoComplete="current-password" /> setNewPassword(e.target.value)} placeholder="New password" autoComplete="new-password" minLength={8} /> setConfirmPassword(e.target.value)} placeholder="Confirm new password" autoComplete="new-password" minLength={8} /> {passwordError &&

{passwordError}

} {passwordSuccess &&

Password updated.

}

Active sessions
{sessionsError &&

{sessionsError}

} {sessions.length === 0 ? (

No active sessions.

) : ( sessions.map((session) => (
{session.device_label} {session.is_current && ' · This device'}
{session.ip_address ?? 'Unknown location'} · last active{' '} {new Date(session.last_seen_at).toLocaleString()}
)) )}
) }