Skip to content

Commit 070fa4e

Browse files
committed
feat: add confirmed profile deletion
1 parent 7677d8f commit 070fa4e

4 files changed

Lines changed: 212 additions & 2 deletions

File tree

src/app.rs

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ pub enum InputMode {
3232
#[default]
3333
None,
3434
CreateProfile,
35+
ConfirmDeleteProfile,
3536
}
3637

3738
/// All state owned by the user interface.
@@ -70,6 +71,8 @@ pub struct App {
7071
pub(crate) send_area: Cell<Rect>,
7172
pub(crate) jump_to_latest_area: Cell<Rect>,
7273
pub(crate) max_chat_scroll: Cell<usize>,
74+
pub(crate) delete_cancel_area: Cell<Rect>,
75+
pub(crate) delete_ok_area: Cell<Rect>,
7376
pub events: EventHandler,
7477
message_cache: HashMap<ChatRef, Vec<Message>>,
7578
simplex_events: mpsc::Receiver<SimplexEvent>,
@@ -113,6 +116,8 @@ impl Default for App {
113116
send_area: Cell::new(Rect::default()),
114117
jump_to_latest_area: Cell::new(Rect::default()),
115118
max_chat_scroll: Cell::new(0),
119+
delete_cancel_area: Cell::new(Rect::default()),
120+
delete_ok_area: Cell::new(Rect::default()),
116121
events: EventHandler::new(),
117122
message_cache: HashMap::new(),
118123
simplex_events,
@@ -170,6 +175,14 @@ impl App {
170175
self.events.send(AppEvent::Quit);
171176
return Ok(());
172177
}
178+
if self.input_mode == InputMode::ConfirmDeleteProfile {
179+
match key.code {
180+
KeyCode::Char('y') => self.confirm_delete_profile(),
181+
KeyCode::Enter | KeyCode::Esc => self.input_mode = InputMode::None,
182+
_ => {}
183+
}
184+
return Ok(());
185+
}
173186
if self.input_mode == InputMode::CreateProfile {
174187
match key.code {
175188
KeyCode::Esc => {
@@ -245,6 +258,12 @@ impl App {
245258
self.input_mode = InputMode::CreateProfile;
246259
self.input.clear();
247260
}
261+
KeyCode::Char('d')
262+
if self.section == Section::Profiles
263+
&& self.selected_profile < self.profiles.len() =>
264+
{
265+
self.input_mode = InputMode::ConfirmDeleteProfile;
266+
}
248267
KeyCode::Enter | KeyCode::Char(' ') if self.section == Section::Settings => {
249268
self.activate_setting()
250269
}
@@ -314,6 +333,16 @@ impl App {
314333
}
315334

316335
fn handle_mouse_event(&mut self, kind: MouseEventKind, column: u16, row: u16) {
336+
if self.input_mode == InputMode::ConfirmDeleteProfile
337+
&& matches!(kind, MouseEventKind::Down(MouseButton::Left))
338+
{
339+
if self.delete_ok_area.get().contains((column, row).into()) {
340+
self.confirm_delete_profile();
341+
} else if self.delete_cancel_area.get().contains((column, row).into()) {
342+
self.input_mode = InputMode::None;
343+
}
344+
return;
345+
}
317346
if matches!(kind, MouseEventKind::Down(MouseButton::Left)) {
318347
if self
319348
.jump_to_latest_area
@@ -482,6 +511,26 @@ impl App {
482511
self.notice = Some("Profile created".into());
483512
self.sync_selected_profile();
484513
}
514+
SimplexEvent::ProfileDeleted {
515+
profiles,
516+
active_user,
517+
chats,
518+
} => {
519+
self.profiles = profiles;
520+
self.chats = chats;
521+
self.messages.clear();
522+
self.message_cache.clear();
523+
self.loaded_chat = None;
524+
self.selected_chat = 0;
525+
self.startup = active_user
526+
.map(StartupState::Ready)
527+
.unwrap_or(StartupState::NoActiveUser);
528+
self.notice = Some("Profile deleted".into());
529+
self.sync_selected_profile();
530+
}
531+
SimplexEvent::ProfileDeleteFailed(error) => {
532+
self.notice = Some(format!("Could not delete profile: {error}"));
533+
}
485534
SimplexEvent::SettingChanged(message) => self.notice = Some(message),
486535
SimplexEvent::AutoDeleteLoaded(seconds) => self.auto_delete_seconds = seconds,
487536
SimplexEvent::ServersLoaded(servers) => self.smp_servers = servers,
@@ -734,6 +783,19 @@ impl App {
734783
.send(SimplexCommand::ActivateProfile(profile.id));
735784
}
736785

786+
fn confirm_delete_profile(&mut self) {
787+
let Some(profile) = self.profiles.get(self.selected_profile) else {
788+
self.input_mode = InputMode::None;
789+
return;
790+
};
791+
let user_id = profile.id;
792+
self.input_mode = InputMode::None;
793+
self.notice = Some(format!("Deleting profile {}…", profile.display_name));
794+
let _ = self
795+
.simplex_commands
796+
.send(SimplexCommand::DeleteProfile(user_id));
797+
}
798+
737799
fn activate_setting(&mut self) {
738800
match self.selected_setting {
739801
1 => {
@@ -1059,4 +1121,37 @@ mod tests {
10591121
app.tick();
10601122
assert_eq!(app.messages[0].text, "from background");
10611123
}
1124+
1125+
#[tokio::test]
1126+
async fn profile_delete_requires_explicit_confirmation() {
1127+
let (commands, receiver) = mpsc::channel();
1128+
let mut app = App {
1129+
section: Section::Profiles,
1130+
profiles: vec![Profile {
1131+
id: 9,
1132+
display_name: "work".into(),
1133+
notifications: true,
1134+
active: true,
1135+
}],
1136+
simplex_commands: commands,
1137+
..App::default()
1138+
};
1139+
1140+
app.handle_key_events(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE))
1141+
.unwrap();
1142+
assert_eq!(app.input_mode, InputMode::ConfirmDeleteProfile);
1143+
app.handle_key_events(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
1144+
.unwrap();
1145+
assert_eq!(app.input_mode, InputMode::None);
1146+
assert!(receiver.try_recv().is_err());
1147+
1148+
app.handle_key_events(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE))
1149+
.unwrap();
1150+
app.handle_key_events(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE))
1151+
.unwrap();
1152+
let SimplexCommand::DeleteProfile(user_id) = receiver.try_recv().unwrap() else {
1153+
panic!("expected delete-profile command")
1154+
};
1155+
assert_eq!(user_id, 9);
1156+
}
10621157
}

src/chat.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,12 @@ pub enum SimplexEvent {
5353
profiles: Vec<Profile>,
5454
chats: Vec<ChatSummary>,
5555
},
56+
ProfileDeleted {
57+
profiles: Vec<Profile>,
58+
active_user: Option<User>,
59+
chats: Vec<ChatSummary>,
60+
},
61+
ProfileDeleteFailed(String),
5662
SettingChanged(String),
5763
AutoDeleteLoaded(i64),
5864
ServersLoaded(Vec<String>),

src/simplex_worker.rs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ pub enum SimplexCommand {
2222
},
2323
ActivateProfile(i64),
2424
CreateProfile(String),
25+
DeleteProfile(i64),
2526
SetNotifications {
2627
user_id: i64,
2728
enabled: bool,
@@ -49,6 +50,12 @@ pub enum ChatFeature {
4950
FilesAndMedia,
5051
}
5152

53+
struct DeletedProfileState {
54+
profiles: Vec<chat::Profile>,
55+
active_user: Option<chat::User>,
56+
chats: Vec<chat::ChatSummary>,
57+
}
58+
5259
pub fn spawn(api: Arc<SimplexApi>, sender: Sender<SimplexEvent>) -> Sender<SimplexCommand> {
5360
let (command_sender, commands) = mpsc::channel();
5461
thread::Builder::new()
@@ -224,6 +231,19 @@ fn command_loop(
224231
.send(result.unwrap_or_else(SimplexEvent::Failed))
225232
.map_err(|e| e.to_string())?;
226233
}
234+
SimplexCommand::DeleteProfile(user_id) => {
235+
let result = delete_profile(&controller, user_id);
236+
sender
237+
.send(match result {
238+
Ok(state) => SimplexEvent::ProfileDeleted {
239+
profiles: state.profiles,
240+
active_user: state.active_user,
241+
chats: state.chats,
242+
},
243+
Err(error) => SimplexEvent::ProfileDeleteFailed(error),
244+
})
245+
.map_err(|e| e.to_string())?;
246+
}
227247
SimplexCommand::SetNotifications { user_id, enabled } => {
228248
let action = if enabled { "unmute" } else { "mute" };
229249
let response = controller
@@ -300,6 +320,39 @@ fn command_loop(
300320
}
301321
}
302322

323+
fn delete_profile(
324+
controller: &crate::simplex::SimplexController,
325+
user_id: i64,
326+
) -> Result<DeletedProfileState, String> {
327+
let profiles = load_profiles(controller)?;
328+
if profiles
329+
.iter()
330+
.any(|profile| profile.id == user_id && profile.active)
331+
&& let Some(other) = profiles.iter().find(|profile| profile.id != user_id)
332+
{
333+
let response = controller
334+
.command(&format!("/_user {}", other.id))
335+
.map_err(|e| e.to_string())?;
336+
chat::active_user(&response)?.ok_or("SimpleX did not activate the replacement profile")?;
337+
}
338+
let response = controller
339+
.command(&format!("/_delete user {user_id} del_smp=on"))
340+
.map_err(|e| e.to_string())?;
341+
ensure_ok(&response, "profile deletion")?;
342+
let profiles = load_profiles(controller)?;
343+
let active_user = chat::active_user(&controller.command("/u").map_err(|e| e.to_string())?)?;
344+
let chats = if let Some(user) = &active_user {
345+
load_chats(controller, user.id)?
346+
} else {
347+
Vec::new()
348+
};
349+
Ok(DeletedProfileState {
350+
profiles,
351+
active_user,
352+
chats,
353+
})
354+
}
355+
303356
fn mark_chat_read(
304357
controller: &crate::simplex::SimplexController,
305358
sender: &Sender<SimplexEvent>,

src/ui.rs

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ use ratatui::{
44
layout::{Alignment, Constraint, Direction, Layout, Rect},
55
style::{Color, Modifier, Style, Stylize},
66
text::{Line, Span},
7-
widgets::{Block, BorderType, Borders, List, ListItem, Padding, Paragraph, Tabs, Widget, Wrap},
7+
widgets::{
8+
Block, BorderType, Borders, Clear, List, ListItem, Padding, Paragraph, Tabs, Widget, Wrap,
9+
},
810
};
911
use tui_qrcode::{Colors, QrCodeWidget, Scaling};
1012

@@ -32,6 +34,8 @@ impl Widget for &App {
3234
self.composer_area.set(Rect::default());
3335
self.send_area.set(Rect::default());
3436
self.jump_to_latest_area.set(Rect::default());
37+
self.delete_cancel_area.set(Rect::default());
38+
self.delete_ok_area.set(Rect::default());
3539
let columns = Layout::default()
3640
.direction(Direction::Horizontal)
3741
.constraints([Constraint::Percentage(32), Constraint::Percentage(68)])
@@ -44,6 +48,9 @@ impl Widget for &App {
4448
render_tabs(self, sidebar[0], buf);
4549
render_sidebar(self, sidebar[1], buf);
4650
render_detail(self, columns[1], buf);
51+
if self.input_mode == InputMode::ConfirmDeleteProfile {
52+
render_delete_profile_confirmation(self, area, buf);
53+
}
4754
}
4855
}
4956

@@ -173,7 +180,7 @@ fn render_profile(app: &App, area: Rect, buf: &mut Buffer) {
173180
}
174181
let profile = &app.profiles[app.selected_profile];
175182
Paragraph::new(format!(
176-
"Display name\n{}\n\nNotifications\n{}\n\nStatus\n{}\n\nEnter: activate · n: new profile",
183+
"Display name\n{}\n\nNotifications\n{}\n\nStatus\n{}\n\nEnter: activate · n: new profile · d: delete",
177184
profile.display_name,
178185
enabled(profile.notifications),
179186
if profile.active { "Active" } else { "Inactive" },
@@ -183,6 +190,55 @@ fn render_profile(app: &App, area: Rect, buf: &mut Buffer) {
183190
.render(area, buf);
184191
}
185192

193+
fn render_delete_profile_confirmation(app: &App, area: Rect, buf: &mut Buffer) {
194+
let Some(profile) = app.profiles.get(app.selected_profile) else {
195+
return;
196+
};
197+
let width = area.width.min(68);
198+
let height = area.height.min(9);
199+
let popup = Rect::new(
200+
area.x + area.width.saturating_sub(width) / 2,
201+
area.y + area.height.saturating_sub(height) / 2,
202+
width,
203+
height,
204+
);
205+
Clear.render(popup, buf);
206+
let block = Block::bordered()
207+
.border_type(BorderType::Rounded)
208+
.border_style(Style::default().fg(Color::Red))
209+
.title(" Delete profile ");
210+
let inner = block.inner(popup);
211+
block.render(popup, buf);
212+
let rows = Layout::vertical([Constraint::Min(2), Constraint::Length(3)]).split(inner);
213+
Paragraph::new(format!(
214+
"Are you sure that you want to delete profile {}?",
215+
profile.display_name
216+
))
217+
.alignment(Alignment::Center)
218+
.wrap(Wrap { trim: false })
219+
.render(rows[0], buf);
220+
let buttons =
221+
Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]).split(rows[1]);
222+
app.delete_cancel_area.set(buttons[0]);
223+
app.delete_ok_area.set(buttons[1]);
224+
Paragraph::new("Cancel (Enter)")
225+
.alignment(Alignment::Center)
226+
.block(
227+
Block::bordered()
228+
.border_type(BorderType::Rounded)
229+
.border_style(Style::default().fg(Color::Cyan)),
230+
)
231+
.render(buttons[0], buf);
232+
Paragraph::new("OK (y)")
233+
.alignment(Alignment::Center)
234+
.block(
235+
Block::bordered()
236+
.border_type(BorderType::Rounded)
237+
.border_style(Style::default().fg(Color::Red)),
238+
)
239+
.render(buttons[1], buf);
240+
}
241+
186242
fn render_chat(app: &App, area: Rect, buf: &mut Buffer) {
187243
if app.active_user().is_some() && app.selected_chat == app.chats.len() {
188244
render_invitation(app, area, buf);

0 commit comments

Comments
 (0)