Compare commits

..

2 commits

Author SHA1 Message Date
Avi
832e1144f0 checkpoint: fix delete/undo + themed auto-dismiss bar 2026-08-27 12:09:53 -05:00
Avi
9e635e7a93 fix: restore profile delete/undo and themed auto-dismiss undo bar
- ipc: add DeleteProfile/UndoDelete requests returning state_view
- profiles: expose delete_profile outside tests
- api: deleteProfile/undoDelete now return AppState; AppProvider uses applyState
- fakeBackend: implement delete/undo handlers
- ProfilesScreen: themed 'Profile deleted — Undo and restore profile' (primary color, primary-soft) with 5s auto-dismiss, shown in both empty and grid states
2026-08-27 12:07:13 -05:00
7 changed files with 189 additions and 1477 deletions

File diff suppressed because it is too large Load diff

View file

@ -106,8 +106,8 @@ export const api = {
signerApprove: (id: string, approved: boolean) => signerApprove: (id: string, approved: boolean) =>
call<SignerStatus>('signer_approve', { id, approved }), call<SignerStatus>('signer_approve', { id, approved }),
deleteProfile: (npub: string) => call<ProfileSummary>('delete_profile', { npub }), deleteProfile: (npub: string) => call<AppState>('delete_profile', { npub }),
undoDelete: () => call<ProfileSummary>('undo_delete'), undoDelete: () => call<AppState>('undo_delete'),
copyText: (text: string) => window.backend.copyText(text), copyText: (text: string) => window.backend.copyText(text),
}; };

View file

@ -1,4 +1,4 @@
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { Avatar } from '../components/Avatar'; import { Avatar } from '../components/Avatar';
import { Badge } from '../components/Badge'; import { Badge } from '../components/Badge';
import { Button } from '../components/Button'; import { Button } from '../components/Button';
@ -17,7 +17,14 @@ interface ProfilesScreenProps {
} }
export function ProfilesScreen({ onCreateProfile }: 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<string | null>(null); const [selecting, setSelecting] = useState<string | null>(null);
const [publishing, setPublishing] = useState<string | null>(null); const [publishing, setPublishing] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -33,6 +40,14 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
const undoHistory = state?.undo_history ?? []; const undoHistory = state?.undo_history ?? [];
const lastDeleted = undoHistory[undoHistory.length - 1] ?? null; 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) => { const onPublishName = async (npub: string, label: string) => {
setError(null); setError(null);
setNotice(null); setNotice(null);
@ -63,19 +78,34 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
className="undo-bar" className="undo-bar"
style={{ style={{
margin: '12px 0', margin: '12px 0',
padding: '8px 12px', padding: '10px 14px',
background: 'var(--token-item-bg, #f0f0f0)', background: 'var(--primary-soft)',
borderRadius: '4px', border: '1px solid var(--border)',
borderRadius: '8px',
display: 'flex',
alignItems: 'center',
gap: '8px',
}} }}
> >
<Button <span style={{ color: 'var(--text)', fontWeight: 500 }}>
variant="secondary" Profile “{lastDeleted.label}” deleted.
style={{ marginRight: '8px' }} </span>
size="sm" <span style={{ color: 'var(--text-muted)' }}>—</span>
<button
onClick={() => void undoDelete()} onClick={() => void undoDelete()}
style={{
background: 'none',
border: 'none',
padding: 0,
color: 'var(--primary)',
fontWeight: 600,
cursor: 'pointer',
textDecoration: 'underline',
textUnderlineOffset: '2px',
}}
> >
<Icon name="refresh" size={14} /> Restore {lastDeleted.label} Undo and restore profile
</Button> </button>
</div> </div>
)} )}
<EmptyState <EmptyState
@ -130,6 +160,43 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
</p> </p>
)} )}
{lastDeleted && (
<div
className="undo-bar"
style={{
margin: '12px 0',
padding: '10px 14px',
background: 'var(--primary-soft)',
border: '1px solid var(--border)',
borderRadius: '8px',
display: 'flex',
alignItems: 'center',
gap: '8px',
flexWrap: 'wrap',
}}
>
<span style={{ color: 'var(--text)', fontWeight: 500 }}>
Profile “{lastDeleted.label}” deleted.
</span>
<span style={{ color: 'var(--text-muted)' }}>—</span>
<button
onClick={() => void undoDelete()}
style={{
background: 'none',
border: 'none',
padding: 0,
color: 'var(--primary)',
fontWeight: 600,
cursor: 'pointer',
textDecoration: 'underline',
textUnderlineOffset: '2px',
}}
>
Undo and restore profile
</button>
</div>
)}
<div className="profile-grid"> <div className="profile-grid">
{profiles.map((profile) => ( {profiles.map((profile) => (
<article <article

View file

@ -71,8 +71,9 @@ interface AppContextValue {
signerDisconnect: () => Promise<SignerStatus>; signerDisconnect: () => Promise<SignerStatus>;
signerStatus: () => Promise<SignerStatus>; signerStatus: () => Promise<SignerStatus>;
signerApprove: (id: string, approved: boolean) => Promise<SignerStatus>; signerApprove: (id: string, approved: boolean) => Promise<SignerStatus>;
deleteProfile: (npub: string) => Promise<ProfileSummary>; deleteProfile: (npub: string) => Promise<AppState>;
undoDelete: () => Promise<ProfileSummary>; undoDelete: () => Promise<AppState>;
clearLastDeleted: () => void;
copyText: (text: string) => Promise<void>; copyText: (text: string) => Promise<void>;
} }
@ -234,11 +235,18 @@ export function AppProvider({ children }: { children: ReactNode }) {
return api.signerApprove(id, approved); return api.signerApprove(id, approved);
}, []); }, []);
const deleteProfile = useCallback((npub: string) => api.deleteProfile(npub), []); const deleteProfile = useCallback(
const undoDelete = useCallback(() => api.undoDelete(), []); (npub: string) => applyState(api.deleteProfile(npub)),
[applyState],
);
const undoDelete = useCallback(() => applyState(api.undoDelete()), [applyState]);
const copyText = useCallback((text: string) => api.copyText(text), []); 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(() => { useEffect(() => {
if (state?.settings.theme) { if (state?.settings.theme) {
applyTheme(state.settings.theme); applyTheme(state.settings.theme);
@ -284,6 +292,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
setProfilePicture, setProfilePicture,
renameProfile, renameProfile,
setNip05, setNip05,
clearLastDeleted,
copyText, copyText,
}), }),
[ [
@ -298,6 +307,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
setProfilePicture, setProfilePicture,
renameProfile, renameProfile,
setNip05, setNip05,
clearLastDeleted,
publishNote, publishNote,
deleteProfile, deleteProfile,
undoDelete, undoDelete,

View file

@ -451,6 +451,47 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
return { hex, nsec: `nsec1${npub.slice(5)}` }; 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: default:
throw new Error(`Unknown method: ${method}`); throw new Error(`Unknown method: ${method}`);
} }

View file

@ -146,6 +146,10 @@ pub enum Request {
/// `true` to run the request, `false` to reject it. /// `true` to run the request, `false` to reject it.
approved: bool, approved: bool,
}, },
DeleteProfile {
npub: String,
},
UndoDelete,
} }
/// A reply envelope carrying either data or a safe user-facing error. /// 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<serde_json::Val
app.save_settings()?; app.save_settings()?;
Ok(json!(app.settings)) Ok(json!(app.settings))
} }
Request::DeleteProfile { npub } => {
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 // Signer control requests are handled by `run` before this function is
// reached; keeping a wildcard arm keeps the match exhaustive here. // reached; keeping a wildcard arm keeps the match exhaustive here.
_ => Err(AppError::internal("Unexpected signer request.")), _ => Err(AppError::internal("Unexpected signer request.")),

View file

@ -596,6 +596,28 @@ pub fn parse_secret_key(hex_str: &str) -> Result<SecretKey, AppError> {
SecretKey::from_slice(&bytes).map_err(|e| AppError::invalid_secret(format!("{e}"))) 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<ProfileSummary, AppError> {
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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -1060,27 +1082,4 @@ mod tests {
.expect_err("missing profile must error"); .expect_err("missing profile must error");
assert_eq!(err.kind(), crate::errors::ErrorKind::ProfileNotFound); 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<ProfileSummary, AppError> {
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,
})
}
} }