import { useState, useEffect, useRef } from 'react';
import { useAuthStore } from '../../../stores/authStore';
import { useInstanceStore } from '../../../stores/instanceStore';
import { Avatar } from '../../ui/Avatar';
import { ImageCropModal } from '../../ui/ImageCropModal';
import { DeleteAccountModal } from '../DeleteAccountModal';
import { api } from '../../../api/client';
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';
import { isElectron } from '../../../platform/platform';
import { Toggle } from '../../ui/Toggle';
function AutoLaunchSettings() {
const [openAtLogin, setOpenAtLogin] = useState(false);
const [startMinimized, setStartMinimized] = useState(true);
const [loading, setLoading] = useState(true);
useEffect(() => {
window.backspace?.getAutoLaunchSettings().then((settings) => {
setOpenAtLogin(settings.openAtLogin);
setStartMinimized(settings.startMinimized);
setLoading(false);
}).catch(() => setLoading(false));
}, []);
const handleOpenAtLoginChange = async (enabled: boolean) => {
setOpenAtLogin(enabled);
try {
const result = await window.backspace!.setAutoLaunchSettings({ openAtLogin: enabled });
setOpenAtLogin(result.openAtLogin);
setStartMinimized(result.startMinimized);
} catch {
setOpenAtLogin(!enabled);
}
};
const handleStartMinimizedChange = async (enabled: boolean) => {
setStartMinimized(enabled);
try {
const result = await window.backspace!.setAutoLaunchSettings({ startMinimized: enabled });
setOpenAtLogin(result.openAtLogin);
setStartMinimized(result.startMinimized);
} catch {
setStartMinimized(!enabled);
}
};
if (loading) return null;
return (
<>
Start at boot
Automatically launch Backspace when you log in
Start minimized
Start hidden in the system tray instead of showing the window
>
);
}
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 bannerInputRef = useRef(null);
const [error, setError] = useState('');
const [success, setSuccess] = 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);
if (bannerPreview) URL.revokeObjectURL(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 [passwordSuccess, setPasswordSuccess] = 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);
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 attachment = await api.uploads.upload(file);
setAvatarFilename(attachment.filename);
} catch {
setError('Failed to upload avatar');
setAvatarPreview(null);
URL.revokeObjectURL(previewUrl);
} finally {
setUploadingAvatar(false);
}
};
const handleBannerCropComplete = async (blob: Blob) => {
if (bannerPreview) URL.revokeObjectURL(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 attachment = await api.uploads.upload(file);
setBannerFilename(attachment.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('');
};
const handleRemoveBanner = () => {
if (bannerPreview) URL.revokeObjectURL(bannerPreview);
setBannerPreview(null);
setBannerFilename('');
};
const handleSave = async () => {
setError('');
setSuccess('');
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]);
setSuccess('Profile updated!');
setTimeout(() => setSuccess(''), 2000);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update profile');
} finally {
setIsLoading(false);
}
};
const handleChangePassword = async () => {
setPasswordError('');
setPasswordSuccess('');
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);
setPasswordSuccess('Password changed successfully!');
setCurrentPassword('');
setNewPassword('');
setConfirmNewPassword('');
if (results.length > 0) {
setPasswordResults(results);
}
setTimeout(() => {
setPasswordSuccess('');
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);
if (bannerPreview) URL.revokeObjectURL(bannerPreview);
setAvatarPreview(null);
setAvatarFilename(null);
setBannerPreview(null);
setBannerFilename(null);
setError('');
};
return (
{/* ── 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 */}
{/* 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 */}
{/* ── Account ── */}
{/* ── Password ── */}
{/* ── Desktop (Electron only) ── */}
{isElectron() && (
Desktop
{window.location.origin}
Currently connected instance
)}
{/* ── Danger Zone ── */}
Danger Zone
Once you delete your account, there is no going back. Your messages will remain but be attributed to "Deleted User".
{error && (
{error}
)}
{success && (
{success}
)}
{hasChanges && (
)}
{/* Crop Modals */}
setAvatarCropSrc(null)}
imageSrc={avatarCropSrc ?? ''}
onCropComplete={handleAvatarCropComplete}
title="Crop Avatar"
cropShape="round"
aspectRatio={1}
maxOutputDimension={256}
/>
setBannerCropSrc(null)}
imageSrc={bannerCropSrc ?? ''}
onCropComplete={handleBannerCropComplete}
title="Crop Banner"
cropShape="rect"
aspectRatio={3}
maxOutputDimension={1280}
/>
setShowDeleteModal(false)}
/>
);
}