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
This commit is contained in:
parent
56c2a6d836
commit
9e635e7a93
6 changed files with 172 additions and 40 deletions
|
|
@ -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),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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}`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
15
src/ipc.rs
15
src/ipc.rs
|
|
@ -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.")),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue