Files
ds-chat/frontend/src/components/ProfileModal.tsx
T
ksmithandClaude Sonnet 5 520b971247 Add adjustable text size and emoji size preferences (#71)
Text was too small on high-DPI screens with no in-app fix beyond
browser zoom. Adds a text-size setting (scales the whole app via a
root font-size percentage), auto-large rendering for emoji-only
messages, and an independent emoji-size preference that also scales
reaction pills without affecting the emoji picker's fixed-size grid.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 18:30:49 -06:00

634 lines
23 KiB
TypeScript

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<CustomThemeColors, 'color_scheme'>; 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<string | null>(null)
const [savingName, setSavingName] = useState(false)
const [uploadingAvatar, setUploadingAvatar] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const [themeError, setThemeError] = useState<string | null>(null)
const [textScaleError, setTextScaleError] = useState<string | null>(null)
const [emojiScaleError, setEmojiScaleError] = useState<string | null>(null)
const [customThemes, setCustomThemes] = useState<CustomTheme[]>([])
const [editingThemeId, setEditingThemeId] = useState<string | null>(null)
const [editNameDraft, setEditNameDraft] = useState('')
const [editColorsDraft, setEditColorsDraft] = useState<CustomThemeColors>(DEFAULT_CUSTOM_COLORS)
const [savingThemeEdit, setSavingThemeEdit] = useState(false)
const [highlightedField, setHighlightedField] = useState<keyof CustomThemeColors | null>(null)
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [passwordError, setPasswordError] = useState<string | null>(null)
const [passwordSuccess, setPasswordSuccess] = useState(false)
const [savingPassword, setSavingPassword] = useState(false)
const [sessions, setSessions] = useState<UserSession[]>([])
const [sessionsError, setSessionsError] = useState<string | null>(null)
const [revokingSessionId, setRevokingSessionId] = useState<string | null>(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<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))
}
}
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 (
<div className="modal-scrim" onClick={handleClose}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2>Profile settings</h2>
<button type="button" className="modal-close" onClick={handleClose} aria-label="Close">
&times;
</button>
</div>
<div className="profile-modal-avatar-row">
<UserAvatar
username={user.username}
colorIndex={hashIndex(user.username)}
size={64}
avatarUrl={avatarUrl}
status={user.appear_offline ? 'offline' : 'online'}
/>
<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>
<hr className="modal-divider" />
<div className="modal-field-label">Theme</div>
<div className="theme-swatch-grid">
{THEME_OPTIONS.map((option) => (
<button
key={option.name}
type="button"
className={`theme-swatch theme-swatch-${option.name}${
(user.theme ?? 'dark') === option.name ? ' theme-swatch-selected' : ''
}`}
onClick={() => handleSelectPreset(option.name)}
aria-pressed={(user.theme ?? 'dark') === option.name}
>
<span className="theme-swatch-preview" aria-hidden="true">
<span className="theme-swatch-accent" />
</span>
<span className="theme-swatch-label">{option.label}</span>
</button>
))}
</div>
{themeError && <p className="modal-error">{themeError}</p>}
<div className="modal-field-label">Text size</div>
<div className="text-scale-options">
{TEXT_SCALE_OPTIONS.map((option) => (
<button
key={option.name}
type="button"
className={`text-scale-option${
(user.text_scale ?? 'normal') === option.name ? ' text-scale-option-selected' : ''
}`}
onClick={() => handleSelectTextScale(option.name)}
aria-pressed={(user.text_scale ?? 'normal') === option.name}
>
<span className="text-scale-option-preview" style={{ fontSize: option.previewSize }}>
Aa
</span>
<span className="text-scale-option-label">{option.label}</span>
</button>
))}
</div>
{textScaleError && <p className="modal-error">{textScaleError}</p>}
<div className="modal-field-label">Emoji size</div>
<div className="text-scale-options">
{EMOJI_SCALE_OPTIONS.map((option) => (
<button
key={option.name}
type="button"
className={`text-scale-option${
(user.emoji_scale ?? 'normal') === option.name ? ' text-scale-option-selected' : ''
}`}
onClick={() => handleSelectEmojiScale(option.name)}
aria-pressed={(user.emoji_scale ?? 'normal') === option.name}
>
<span className="text-scale-option-preview" style={{ fontSize: option.previewSize }}>
🎉
</span>
<span className="text-scale-option-label">{option.label}</span>
</button>
))}
</div>
{emojiScaleError && <p className="modal-error">{emojiScaleError}</p>}
<div className="modal-field-label">My custom themes</div>
<div className="theme-swatch-grid">
{customThemes.map((theme) => {
const active = user.theme === 'custom' && user.active_custom_theme?.id === theme.id
return (
<div key={theme.id} className={`custom-theme-swatch${active ? ' theme-swatch-selected' : ''}`}>
<button
type="button"
className="theme-swatch custom-theme-swatch-select"
onClick={() => handleActivateCustomTheme(theme)}
aria-pressed={active}
>
<span
className="theme-swatch-preview"
aria-hidden="true"
style={{ background: theme.colors.void }}
>
<span className="theme-swatch-accent" style={{ background: theme.colors.accent }} />
</span>
<span className="theme-swatch-label">{theme.name}</span>
</button>
<div className="custom-theme-swatch-actions">
<button
type="button"
className="custom-theme-swatch-icon-btn"
onClick={() => (editingThemeId === theme.id ? closeEditor() : openEditor(theme))}
aria-label={`Edit ${theme.name}`}
title="Edit"
>
<svg width="13" height="13" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path
d="M13.5 3.5 16.5 6.5 7 16 3 17 4 13 13.5 3.5Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinejoin="round"
/>
</svg>
</button>
<button
type="button"
className="custom-theme-swatch-icon-btn"
onClick={() => handleDeleteCustomTheme(theme)}
aria-label={`Delete ${theme.name}`}
title="Delete"
>
<svg width="13" height="13" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path
d="M4 6h12M8 6V4h4v2m-6 0 .7 10.5A1 1 0 0 0 7.7 17h4.6a1 1 0 0 0 1-.95L14 6"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</div>
</div>
)
})}
<button type="button" className="theme-swatch custom-theme-new" onClick={handleCreateCustomTheme}>
<span className="theme-swatch-preview theme-swatch-preview-new" aria-hidden="true">
+
</span>
<span className="theme-swatch-label">New</span>
</button>
</div>
{editingTheme && (
<ThemeBuilderModal
colorFields={CUSTOM_COLOR_FIELDS}
name={editNameDraft}
onNameChange={setEditNameDraft}
colors={editColorsDraft}
onColorChange={handleEditColorChange}
highlightedField={highlightedField}
onHighlight={setHighlightedField}
error={themeError}
saving={savingThemeEdit}
onSave={handleSaveThemeEdit}
onCancel={closeEditor}
/>
)}
<hr className="modal-divider" />
<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={handleClose}>
Close
</button>
<button type="submit" className="btn-primary" disabled={savingName}>
Save
</button>
</div>
</form>
<hr className="modal-divider" />
<form onSubmit={handleChangePassword}>
<div className="modal-field-label">Change password</div>
<input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
placeholder="Current password"
autoComplete="current-password"
/>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="New password"
autoComplete="new-password"
minLength={8}
/>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Confirm new password"
autoComplete="new-password"
minLength={8}
/>
{passwordError && <p className="modal-error">{passwordError}</p>}
{passwordSuccess && <p className="modal-success">Password updated.</p>}
<div className="modal-actions">
<button
type="submit"
className="btn-primary"
disabled={savingPassword || !currentPassword || !newPassword || !confirmPassword}
>
{savingPassword ? 'Saving…' : 'Update password'}
</button>
</div>
</form>
<hr className="modal-divider" />
<div className="modal-field-label">Active sessions</div>
{sessionsError && <p className="modal-error">{sessionsError}</p>}
{sessions.length === 0 ? (
<p className="modal-empty">No active sessions.</p>
) : (
sessions.map((session) => (
<div key={session.id} className="modal-list-row">
<div className="modal-list-row-body">
<div className="modal-list-row-title">
{session.device_label}
{session.is_current && ' · This device'}
</div>
<div className="modal-list-row-sub">
{session.ip_address ?? 'Unknown location'} · last active{' '}
{new Date(session.last_seen_at).toLocaleString()}
</div>
</div>
<button
type="button"
className="modal-list-row-action"
disabled={revokingSessionId === session.id}
onClick={() => handleRevokeSession(session)}
>
{session.is_current ? 'Sign out' : 'Revoke'}
</button>
</div>
))
)}
</div>
</div>
)
}