diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index cd8cae6..8f0f7d6 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -106,8 +106,8 @@ export const api = { signerApprove: (id: string, approved: boolean) => call('signer_approve', { id, approved }), - deleteProfile: (npub: string) => call('delete_profile', { npub }), - undoDelete: () => call('undo_delete'), + deleteProfile: (npub: string) => call('delete_profile', { npub }), + undoDelete: () => call('undo_delete'), copyText: (text: string) => window.backend.copyText(text), }; diff --git a/frontend/src/screens/ProfilesScreen.tsx b/frontend/src/screens/ProfilesScreen.tsx index 92b02d7..f74c483 100644 --- a/frontend/src/screens/ProfilesScreen.tsx +++ b/frontend/src/screens/ProfilesScreen.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { Avatar } from '../components/Avatar'; import { Badge } from '../components/Badge'; import { Button } from '../components/Button'; @@ -17,7 +17,14 @@ interface ProfilesScreenProps { } export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) { - const { state, selectProfile, deleteProfile, undoDelete, publishProfileMetadata } = useApp(); + const { + state, + selectProfile, + deleteProfile, + undoDelete, + publishProfileMetadata, + clearLastDeleted, + } = useApp(); const [selecting, setSelecting] = useState(null); const [publishing, setPublishing] = useState(null); const [error, setError] = useState(null); @@ -33,6 +40,14 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) { const undoHistory = state?.undo_history ?? []; const lastDeleted = undoHistory[undoHistory.length - 1] ?? null; + useEffect(() => { + if (!lastDeleted) return; + const timeout = setTimeout(() => { + clearLastDeleted(); + }, 5000); + return () => clearTimeout(timeout); + }, [lastDeleted, clearLastDeleted]); + const onPublishName = async (npub: string, label: string) => { setError(null); setNotice(null); @@ -63,19 +78,34 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) { className="undo-bar" style={{ margin: '12px 0', - padding: '8px 12px', - background: 'var(--token-item-bg, #f0f0f0)', - borderRadius: '4px', + padding: '10px 14px', + background: 'var(--primary-soft)', + border: '1px solid var(--border)', + borderRadius: '8px', + display: 'flex', + alignItems: 'center', + gap: '8px', }} > - + Undo and restore profile + )} )} + {lastDeleted && ( +
+ + Profile “{lastDeleted.label}” deleted. + + — + +
+ )} +
{profiles.map((profile) => (
Promise; signerStatus: () => Promise; signerApprove: (id: string, approved: boolean) => Promise; - deleteProfile: (npub: string) => Promise; - undoDelete: () => Promise; + deleteProfile: (npub: string) => Promise; + undoDelete: () => Promise; + clearLastDeleted: () => void; copyText: (text: string) => Promise; } @@ -234,11 +235,18 @@ export function AppProvider({ children }: { children: ReactNode }) { return api.signerApprove(id, approved); }, []); - const deleteProfile = useCallback((npub: string) => api.deleteProfile(npub), []); - const undoDelete = useCallback(() => api.undoDelete(), []); + const deleteProfile = useCallback( + (npub: string) => applyState(api.deleteProfile(npub)), + [applyState], + ); + const undoDelete = useCallback(() => applyState(api.undoDelete()), [applyState]); const copyText = useCallback((text: string) => api.copyText(text), []); + const clearLastDeleted = useCallback(() => { + setState((prev) => (prev ? { ...prev, undo_history: prev.undo_history?.slice(0, -1) } : prev)); + }, []); + useEffect(() => { if (state?.settings.theme) { applyTheme(state.settings.theme); @@ -284,6 +292,7 @@ export function AppProvider({ children }: { children: ReactNode }) { setProfilePicture, renameProfile, setNip05, + clearLastDeleted, copyText, }), [ @@ -298,6 +307,7 @@ export function AppProvider({ children }: { children: ReactNode }) { setProfilePicture, renameProfile, setNip05, + clearLastDeleted, publishNote, deleteProfile, undoDelete, diff --git a/frontend/src/test/fakeBackend.ts b/frontend/src/test/fakeBackend.ts index cd5d0d7..ccef5e5 100644 --- a/frontend/src/test/fakeBackend.ts +++ b/frontend/src/test/fakeBackend.ts @@ -451,6 +451,47 @@ export function createFakeBackend(initial?: AppState): FakeBackend { return { hex, nsec: `nsec1${npub.slice(5)}` }; } + case 'delete_profile': { + const npub = String(params.npub); + const idx = state.profiles.findIndex((p) => p.npub === npub); + if (idx === -1) throw new Error('That profile is not stored on this computer.'); + const [deleted] = state.profiles.splice(idx, 1); + const wasActive = state.active_profile?.npub === npub; + const next: AppState = { + ...state, + profiles: [...state.profiles], + active_profile: wasActive ? null : state.active_profile, + undo_history: [...(state.undo_history ?? []), { ...deleted, is_active: false }], + }; + // clear active flag on remaining profiles if needed + if (wasActive) { + next.profiles = next.profiles.map((p) => ({ ...p, is_active: false })); + } + backend.setState(next); + return next; + } + + case 'undo_delete': { + const hist = state.undo_history ?? []; + if (hist.length === 0) throw new Error('No profile deletions to undo.'); + const restored = hist[hist.length - 1]; + const next: AppState = { + ...state, + profiles: [...state.profiles, { ...restored, is_active: state.active_profile === null }], + active_profile: state.active_profile ?? { ...restored, is_active: true }, + undo_history: hist.slice(0, -1), + }; + // ensure only one active + if (state.active_profile === null) { + next.profiles = next.profiles.map((p) => ({ + ...p, + is_active: p.npub === restored.npub, + })); + } + backend.setState(next); + return next; + } + default: throw new Error(`Unknown method: ${method}`); } diff --git a/src/ipc.rs b/src/ipc.rs index e1ec407..f5554d9 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -146,6 +146,10 @@ pub enum Request { /// `true` to run the request, `false` to reject it. approved: bool, }, + DeleteProfile { + npub: String, + }, + UndoDelete, } /// A reply envelope carrying either data or a safe user-facing error. @@ -528,6 +532,17 @@ async fn run_with_app(app: &mut App, request: Request) -> Result { + let deleted = profiles::delete_profile(&mut app.vault, &npub)?; + app.save_vault()?; + app.undo_history.push(deleted.clone()); + Ok(json!(app.state_view())) + } + Request::UndoDelete => { + let restored = app.undo_delete()?; + app.save_vault()?; + Ok(json!(app.state_view())) + } // Signer control requests are handled by `run` before this function is // reached; keeping a wildcard arm keeps the match exhaustive here. _ => Err(AppError::internal("Unexpected signer request.")), diff --git a/src/profiles.rs b/src/profiles.rs index b6ba18a..ac55397 100644 --- a/src/profiles.rs +++ b/src/profiles.rs @@ -596,6 +596,28 @@ pub fn parse_secret_key(hex_str: &str) -> Result { SecretKey::from_slice(&bytes).map_err(|e| AppError::invalid_secret(format!("{e}"))) } +/// Delete a profile by npub, returning the deleted profile for undo. +/// The vault must not be encrypted, or the key must be provided. +pub fn delete_profile(vault: &mut Vault, npub: &str) -> Result { + let pos = vault + .profiles + .iter() + .position(|p| p.public_key == npub) + .ok_or_else(|| AppError::profile_not_found(npub))?; + let stored = vault.profiles.remove(pos); + if vault.active_profile.as_deref() == Some(npub) { + vault.active_profile = None; + } + Ok(ProfileSummary { + label: stored.label, + npub: stored.public_key, + created_at: stored.created_at, + is_active: false, + picture: stored.picture, + nip05: stored.nip05, + }) +} + #[cfg(test)] mod tests { use super::*; @@ -1060,27 +1082,4 @@ mod tests { .expect_err("missing profile must error"); assert_eq!(err.kind(), crate::errors::ErrorKind::ProfileNotFound); } - - /// Delete a profile by npub, returning the deleted profile for undo. - /// The vault must not be encrypted, or the key must be provided. - pub fn delete_profile(vault: &mut Vault, npub: &str) -> Result { - let pos = vault - .profiles - .iter() - .position(|p| p.public_key == npub) - .ok_or_else(|| AppError::profile_not_found(npub))?; - let stored = vault.profiles.remove(pos); - // Clear the active_profile if it was the one deleted - if vault.active_profile.as_deref() == Some(npub) { - vault.active_profile = None; - } - Ok(ProfileSummary { - label: stored.label, - npub: stored.public_key, - created_at: stored.created_at, - is_active: false, - picture: stored.picture, - nip05: stored.nip05, - }) - } }