import { useState, useEffect, useRef, useMemo } from 'react'; import { useAuthStore } from '../../../stores/authStore'; import { useUIStore } from '../../../stores/uiStore'; import { useInstanceStore } from '../../../stores/instanceStore'; import { useSpaceStore } from '../../../stores/spaceStore'; import { Avatar } from '../../ui/Avatar'; import { ImageCropModal } from '../../ui/ImageCropModal'; import { GifPicker } from '../../chat/GifPicker'; import { DeleteAccountModal } from '../DeleteAccountModal'; import { api } from '../../../api/client'; import { useTransferStore } from '../../../stores/transferStore'; import { waitForTransferAttachment } from '../../../utils/waitForTransfer'; import { getAvatarGradient, adjustColor, mutedGradient, AVATAR_GRADIENT_MAP, BANNER_COLOR_PRESETS } from '../../../utils/gradients'; import { AVATAR_COLORS } from '@backspace/shared'; import type { User, UserStatus, AvatarColor } from '@backspace/shared'; import type { FederationOpResult } from '../../../utils/federationOps'; /** * Banner/avatar previews hold either a `blob:` object URL (local upload) or a * remote `https:` URL (GIF picker). Only the former owns memory that must be * released — calling revokeObjectURL on a remote URL is a silent no-op that * would quietly hide a mistake here. */ function releasePreview(url: string | null): void { if (url && url.startsWith('blob:')) URL.revokeObjectURL(url); } export function AccountPanel() { const user = useAuthStore((s) => s.user); const updateProfile = useAuthStore((s) => s.updateProfile); const [displayName, setDisplayName] = useState(user?.displayName ?? ''); const [customStatus, setCustomStatus] = useState(user?.customStatus ?? ''); const [status, setStatus] = useState(user?.status ?? 'online'); const [bio, setBio] = useState(user?.bio ?? ''); const [accentColor, setAccentColor] = useState(user?.accentColor ?? null); const [avatarColorState, setAvatarColorState] = useState(user?.avatarColor ?? null); const [customHex, setCustomHex] = useState(user?.accentColor ?? ''); // Avatar upload state const [avatarPreview, setAvatarPreview] = useState(null); const [avatarFilename, setAvatarFilename] = useState(null); const [uploadingAvatar, setUploadingAvatar] = useState(false); const [avatarCropSrc, setAvatarCropSrc] = useState(null); const avatarInputRef = useRef(null); // Banner upload state const [bannerPreview, setBannerPreview] = useState(null); const [bannerFilename, setBannerFilename] = useState(null); const [uploadingBanner, setUploadingBanner] = useState(false); const [bannerCropSrc, setBannerCropSrc] = useState(null); const [showBannerGif, setShowBannerGif] = useState(false); const bannerInputRef = useRef(null); const addToast = useUIStore((s) => s.addToast); const [error, setError] = useState(''); const [isLoading, setIsLoading] = useState(false); useEffect(() => { if (user) { setDisplayName(user.displayName ?? ''); setCustomStatus(user.customStatus ?? ''); setStatus(user.status ?? 'online'); setBio(user.bio ?? ''); setAccentColor(user.accentColor ?? null); setAvatarColorState(user.avatarColor ?? null); setCustomHex(user.accentColor ?? ''); // Reset upload state if (avatarPreview) URL.revokeObjectURL(avatarPreview); releasePreview(bannerPreview); setAvatarPreview(null); setAvatarFilename(null); setBannerPreview(null); setBannerFilename(null); } }, [user?.displayName, user?.customStatus, user?.status, user?.bio, user?.accentColor, user?.avatarColor, user?.avatar, user?.banner]); // Password change state const [currentPassword, setCurrentPassword] = useState(''); const [newPassword, setNewPassword] = useState(''); const [confirmNewPassword, setConfirmNewPassword] = useState(''); const [passwordError, setPasswordError] = useState(''); const [passwordLoading, setPasswordLoading] = useState(false); const [passwordResults, setPasswordResults] = useState(null); const [showCurrentPassword, setShowCurrentPassword] = useState(false); const [showNewPassword, setShowNewPassword] = useState(false); // Delete account state const [showDeleteModal, setShowDeleteModal] = useState(false); const instances = useInstanceStore((s) => s.instances); const changePassword = useAuthStore((s) => s.changePassword); // ── Detached-account re-attach (fallback path, re-attach spec §3.4) ── // Explicit action shown only when this client also holds an active connection // to the account's home domain. Two-step armed confirm names both identities // before minting the proof. The primary/automatic path lives in instanceStore. const [reattachArmed, setReattachArmed] = useState(false); const [reattaching, setReattaching] = useState(false); const [reattachError, setReattachError] = useState(null); const homeConnection = useMemo(() => { if (!user?.homeInstance) return null; const homeDomain = user.homeInstance.replace(/^https?:\/\//, '').replace(/\/+$/, '').toLowerCase(); return instances.find( (i) => i.status === 'connected' // Portless hostname — must agree with the server's extractDomain // (new URL(origin).hostname) so a ported home instance still matches. && new URL(i.origin).hostname.toLowerCase() === homeDomain, ) ?? null; }, [instances, user?.homeInstance]); const handleReattach = async () => { if (!homeConnection) return; if (!reattachArmed) { setReattachArmed(true); return; } setReattaching(true); setReattachError(null); try { // Target domain = THIS instance (where the detached account lives). // Portless hostname to match the server's extractDomain contract. const { token } = await homeConnection.api.auth.attachProof(window.location.hostname); const res = await api.users.reattach({ token }); useAuthStore.getState().setUser(res.user); // Re-attach reconciled this (home) account's 1-on-1 DM federatedIds on the // server; refetch the home DM list so the split conversation collapses // without a reload. try { await useSpaceStore.getState().reloadDmsForOrigin(''); } catch { /* non-fatal */ } addToast(`Account re-linked with ${homeConnection.username}`, 'success', 3000); } catch (err) { setReattachError(err instanceof Error ? err.message : 'Re-attach failed'); } finally { setReattaching(false); setReattachArmed(false); } }; if (!user) return null; const effectiveDisplayName = displayName.trim() || user.username; const effectiveAccent = accentColor; const effectiveAvatarColor = avatarColorState; // Change detection const hasChanges = displayName !== (user.displayName ?? '') || customStatus !== (user.customStatus ?? '') || status !== (user.status ?? 'online') || bio !== (user.bio ?? '') || accentColor !== (user.accentColor ?? null) || avatarColorState !== (user.avatarColor ?? null) || avatarFilename !== null || bannerFilename !== null; // Compute banner display const currentBannerUrl = user.banner ? (user.banner.startsWith('http') ? user.banner : api.uploads.url(user.banner)) : null; const displayBannerSrc = bannerPreview ?? (bannerFilename === '' ? null : currentBannerUrl); // Compute avatar display const currentAvatarSrc = user.avatar ? (user.avatar.startsWith('http') ? user.avatar : api.uploads.url(user.avatar)) : null; const displayAvatarSrc = avatarPreview ?? (avatarFilename === '' ? null : currentAvatarSrc); // Banner fallback: accent gradient or avatar gradient (alpha baked into gradient colors) const bannerFallback = effectiveAccent ? mutedGradient(effectiveAccent, adjustColor(effectiveAccent, -40)) : (() => { const g = getAvatarGradient(user.homeUserId ?? user.id, effectiveDisplayName, effectiveAvatarColor); return mutedGradient(g.from, g.to); })(); // ── File selection handlers ── const handleAvatarSelect = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = () => setAvatarCropSrc(reader.result as string); reader.readAsDataURL(file); if (avatarInputRef.current) avatarInputRef.current.value = ''; }; const handleBannerSelect = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = () => setBannerCropSrc(reader.result as string); reader.readAsDataURL(file); if (bannerInputRef.current) bannerInputRef.current.value = ''; }; // ── Crop complete handlers ── const handleAvatarCropComplete = async (blob: Blob) => { if (avatarPreview) URL.revokeObjectURL(avatarPreview); const previewUrl = URL.createObjectURL(blob); setAvatarPreview(previewUrl); setAvatarCropSrc(null); const file = new File([blob], 'avatar.webp', { type: blob.type || 'image/webp' }); setUploadingAvatar(true); try { const tid = await useTransferStore.getState().startUpload(file, { tray: false }); const { filename } = await waitForTransferAttachment(tid); setAvatarFilename(filename); } catch { setError('Failed to upload avatar'); setAvatarPreview(null); URL.revokeObjectURL(previewUrl); } finally { setUploadingAvatar(false); } }; const handleBannerCropComplete = async (blob: Blob) => { releasePreview(bannerPreview); const previewUrl = URL.createObjectURL(blob); setBannerPreview(previewUrl); setBannerCropSrc(null); const file = new File([blob], 'banner.webp', { type: blob.type || 'image/webp' }); setUploadingBanner(true); try { const tid = await useTransferStore.getState().startUpload(file, { tray: false }); const { filename } = await waitForTransferAttachment(tid); setBannerFilename(filename); } catch { setError('Failed to upload banner'); setBannerPreview(null); URL.revokeObjectURL(previewUrl); } finally { setUploadingBanner(false); } }; const handleRemoveAvatar = () => { if (avatarPreview) URL.revokeObjectURL(avatarPreview); setAvatarPreview(null); setAvatarFilename(''); }; /** * Banners accept absolute URLs end to end: the server's isValidAssetUrl * allows http(s), and the profile render already branches on * `banner.startsWith('http')`. So a picked GIF needs no upload — the remote * URL is stored directly. */ const handleBannerGifSelect = (url: string) => { releasePreview(bannerPreview); setBannerPreview(url); setBannerFilename(url); setShowBannerGif(false); }; const handleRemoveBanner = () => { releasePreview(bannerPreview); setBannerPreview(null); setBannerFilename(''); }; const handleSave = async () => { setError(''); setIsLoading(true); try { const updates: Record = {}; if (displayName !== (user.displayName ?? '')) updates.displayName = displayName.trim(); if (customStatus !== (user.customStatus ?? '')) updates.customStatus = customStatus.trim(); if (status !== (user.status ?? 'online')) updates.status = status; if (bio !== (user.bio ?? '')) updates.bio = bio.trim(); if (accentColor !== (user.accentColor ?? null)) updates.accentColor = accentColor ?? ''; if (avatarColorState !== (user.avatarColor ?? null)) updates.avatarColor = avatarColorState ?? ''; if (avatarFilename !== null) updates.avatar = avatarFilename; if (bannerFilename !== null) updates.banner = bannerFilename; await updateProfile(updates as Parameters[0]); addToast('Profile updated', 'success', 2000); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to update profile'); } finally { setIsLoading(false); } }; const handleChangePassword = async () => { setPasswordError(''); setPasswordResults(null); if (newPassword.length < 8) { setPasswordError('New password must be at least 8 characters'); return; } if (newPassword !== confirmNewPassword) { setPasswordError('Passwords do not match'); return; } setPasswordLoading(true); try { const results = await changePassword(currentPassword, newPassword); addToast('Password changed', 'success', 2000); setCurrentPassword(''); setNewPassword(''); setConfirmNewPassword(''); if (results.length > 0) { setPasswordResults(results); } setTimeout(() => { setPasswordResults(null); }, 5000); } catch (err) { setPasswordError(err instanceof Error ? err.message : 'Failed to change password'); } finally { setPasswordLoading(false); } }; const handleReset = () => { setDisplayName(user.displayName ?? ''); setCustomStatus(user.customStatus ?? ''); setStatus(user.status ?? 'online'); setBio(user.bio ?? ''); setAccentColor(user.accentColor ?? null); setAvatarColorState(user.avatarColor ?? null); setCustomHex(user.accentColor ?? ''); if (avatarPreview) URL.revokeObjectURL(avatarPreview); releasePreview(bannerPreview); setAvatarPreview(null); setAvatarFilename(null); setBannerPreview(null); setBannerFilename(null); setError(''); }; return (

My Account

{user?.federationHomeOrphaned && user?.homeInstance && (
This account is detached from its home instance.{' '} {user.homeInstance} was reset or is no longer available, so this account now operates locally on this instance — your profile and password are managed here. {homeConnection && ( <> {' '}As {homeConnection.username} on{' '} {user.homeInstance}, you can re-link this account — profile and presence will sync from there again. {reattachError &&
{reattachError}
} )}
)} {/* ── Profile Customization ── */}
Profile Customization
{/* Live Preview Card */}
{/* Banner area */}
{/* Avatar + info */}
{effectiveDisplayName}
@{user.username}
{bio.trim() && (
{bio.trim()}
)}
{/* Upload controls */}
{/* Avatar upload */}
{(displayAvatarSrc || user.avatar) && avatarFilename !== '' && ( )}
{/* Banner upload */}
{showBannerGif && ( <> {/* Click-away layer, below the panel but above the page */}
setShowBannerGif(false)} />
)} {(displayBannerSrc || user.banner) && bannerFilename !== '' && ( )}
{/* Avatar Color */}
{AVATAR_COLORS.map((key) => { const entry = AVATAR_GRADIENT_MAP[key]; return (
{/* Banner Color */}
{[0, 1, 2].map((row) => BANNER_COLOR_PRESETS.map((family) => { const color = family[row]!; return (
{ const val = e.target.value; setCustomHex(val); if (/^#[0-9a-fA-F]{6}$/.test(val)) { setAccentColor(val); } }} placeholder="#hex" className="input-standard w-24 px-2 py-1.5 text-xs font-mono" maxLength={7} /> {accentColor && (
)} {accentColor && ( )}
{/* Bio */}