Skip to main content

fractal/components/dialogs/
message_dialogs.rs

1//! Common message dialogs.
2
3use adw::prelude::*;
4use gettextrs::gettext;
5
6use crate::{
7    i18n::gettext_f,
8    ngettext_f,
9    prelude::*,
10    session::{Member, Membership, Room, RoomCategory, User},
11};
12
13/// Show a dialog to confirm leaving a room.
14///
15/// This supports both leaving a joined room and rejecting an invite.
16///
17/// Returns `None` if the user did not confirm.
18pub(crate) async fn confirm_leave_room_dialog(
19    room: &Room,
20    parent: &impl IsA<gtk::Widget>,
21) -> Option<ConfirmLeaveRoomResponse> {
22    let (heading, body, response) = if room.category() == RoomCategory::Invited {
23        // We are rejecting an invite.
24        let heading = gettext("Decline Invite?");
25        let body = if room.join_rule().we_can_join() {
26            gettext(
27                "Do you really want to decline this invite? You can join this room on your own later.",
28            )
29        } else {
30            gettext(
31                "Do you really want to decline this invite? You will not be able to join this room without it.",
32            )
33        };
34        let response = gettext("Decline");
35
36        (heading, body, response)
37    } else {
38        // We are leaving a room that was joined.
39        let heading = gettext("Leave Room?");
40        let body = if room.join_rule().we_can_join() {
41            gettext("Do you really want to leave this room? You can come back later.")
42        } else {
43            gettext(
44                "Do you really want to leave this room? You will not be able to come back without an invitation.",
45            )
46        };
47        let response = gettext("Leave");
48
49        (heading, body, response)
50    };
51
52    // Ask for confirmation.
53    let confirm_dialog = adw::AlertDialog::builder()
54        .default_response("cancel")
55        .heading(heading)
56        .body(body)
57        .build();
58    confirm_dialog.add_responses(&[("cancel", &gettext("Cancel")), ("leave", &response)]);
59    confirm_dialog.set_response_appearance("leave", adw::ResponseAppearance::Destructive);
60
61    let ignore_inviter_switch = if let Some(inviter) = room
62        .inviter()
63        .filter(|_| room.category() == RoomCategory::Invited)
64    {
65        let switch = adw::SwitchRow::builder()
66            .title(gettext_f(
67                "Ignore {user}",
68                &[("user", inviter.user_id().as_str())],
69            ))
70            .subtitle(gettext(
71                "All messages or invitations sent by this user will be ignored",
72            ))
73            .build();
74
75        let list_box = gtk::ListBox::builder()
76            .css_classes(["boxed-list"])
77            .margin_top(6)
78            .accessible_role(gtk::AccessibleRole::Group)
79            .build();
80        list_box.append(&switch);
81        confirm_dialog.set_extra_child(Some(&list_box));
82
83        Some(switch)
84    } else {
85        None
86    };
87
88    if confirm_dialog.choose_future(Some(parent)).await == "leave" {
89        let mut response = ConfirmLeaveRoomResponse::default();
90
91        if let Some(switch) = ignore_inviter_switch {
92            response.ignore_inviter = switch.is_active();
93        }
94
95        Some(response)
96    } else {
97        None
98    }
99}
100
101/// A response to the dialog to confirm leaving a room
102#[derive(Debug, Default, Clone)]
103pub(crate) struct ConfirmLeaveRoomResponse {
104    /// If the room is an invite, whether the user wants to ignore the inviter.
105    pub ignore_inviter: bool,
106}
107
108/// The room member destructive actions that need to be confirmed.
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub(crate) enum RoomMemberDestructiveAction {
111    /// Ban the member.
112    ///
113    /// The value is the number of events that can be redacted for the member.
114    Ban(usize),
115    /// Kick the member.
116    Kick,
117    /// Remove the member's messages.
118    ///
119    /// The value is the number of events that will be redacted.
120    RemoveMessages(usize),
121}
122
123impl RoomMemberDestructiveAction {
124    /// The content of the dialog for this action and the given member.
125    ///
126    /// Returns a `(heading, body, dialog)` tuple.
127    fn dialog_content(self, member: &Member) -> (String, String, Option<String>) {
128        match self {
129            RoomMemberDestructiveAction::Ban(_) => {
130                // Translators: Do NOT translate the content between '{' and '}',
131                // this is a variable name.
132                let heading = gettext_f("Ban {user}?", &[("user", &member.display_name())]);
133                let body = gettext_f(
134                    // Translators: Do NOT translate the content between '{' and '}',
135                    // this is a variable name.
136                    "Are you sure you want to ban {user_id}? They will not be able to join the room again until someone unbans them.",
137                    &[("user_id", member.user_id().as_str())],
138                );
139                let response = gettext("Ban");
140                (heading, body, Some(response))
141            }
142            RoomMemberDestructiveAction::Kick => {
143                let can_rejoin = member.room().join_rule().anyone_can_join();
144
145                match member.membership() {
146                    Membership::Invite => {
147                        let heading = gettext_f(
148                            // Translators: Do NOT translate the content between '{' and '}',
149                            // this is a variable name.
150                            "Revoke Invite for {user}?",
151                            &[("user", &member.display_name())],
152                        );
153                        let body = if can_rejoin {
154                            gettext_f(
155                                // Translators: Do NOT translate the content between '{' and '}',
156                                // this is a variable name.
157                                "Are you sure you want to revoke the invite for {user_id}? They will still be able to join the room on their own.",
158                                &[("user_id", member.user_id().as_str())],
159                            )
160                        } else {
161                            gettext_f(
162                                // Translators: Do NOT translate the content between '{' and '}',
163                                // this is a variable name.
164                                "Are you sure you want to revoke the invite for {user_id}? They will not be able to join the room again until someone reinvites them.",
165                                &[("user_id", member.user_id().as_str())],
166                            )
167                        };
168                        let response = gettext("Revoke Invite");
169                        (heading, body, Some(response))
170                    }
171                    Membership::Knock => {
172                        let heading = gettext_f(
173                            // Translators: Do NOT translate the content between '{' and '}',
174                            // this is a variable name.
175                            "Deny Access to {user}?",
176                            &[("user", &member.display_name())],
177                        );
178                        let body = gettext_f(
179                            // Translators: Do NOT translate the content between '{' and '}',
180                            // this is a variable name.
181                            "Are you sure you want to deny access to {user_id}?",
182                            &[("user_id", member.user_id().as_str())],
183                        );
184                        let response = gettext("Deny Access");
185                        (heading, body, Some(response))
186                    }
187                    _ => {
188                        // Translators: Do NOT translate the content between '{' and '}',
189                        // this is a variable name.
190                        let heading =
191                            gettext_f("Kick {user}?", &[("user", &member.display_name())]);
192                        let body = if can_rejoin {
193                            gettext_f(
194                                // Translators: Do NOT translate the content between '{' and '}',
195                                // this is a variable name.
196                                "Are you sure you want to kick {user_id}? They will still be able to join the room again on their own.",
197                                &[("user_id", member.user_id().as_str())],
198                            )
199                        } else {
200                            gettext_f(
201                                // Translators: Do NOT translate the content between '{' and '}',
202                                // this is a variable name.
203                                "Are you sure you want to kick {user_id}? They will not be able to join the room again until someone invites them.",
204                                &[("user_id", member.user_id().as_str())],
205                            )
206                        };
207                        let response = gettext("Kick");
208                        (heading, body, Some(response))
209                    }
210                }
211            }
212            RoomMemberDestructiveAction::RemoveMessages(count) => {
213                let n = u32::try_from(count).unwrap_or(u32::MAX);
214                if count > 0 {
215                    let heading = gettext_f(
216                        // Translators: Do NOT translate the content between '{' and '}',
217                        // this is a variable name.
218                        "Remove Messages Sent by {user}?",
219                        &[("user", &member.display_name())],
220                    );
221                    let body = ngettext_f(
222                        // Translators: Do NOT translate the content between '{' and '}',
223                        // this is a variable name.
224                        "This removes all the messages received from the homeserver. Are you sure you want to remove 1 message sent by {user_id}? This cannot be undone.",
225                        "This removes all the messages received from the homeserver. Are you sure you want to remove {n} messages sent by {user_id}? This cannot be undone.",
226                        n,
227                        &[
228                            ("n", &n.to_string()),
229                            ("user_id", member.user_id().as_str()),
230                        ],
231                    );
232                    let response = gettext("Remove");
233                    (heading, body, Some(response))
234                } else {
235                    let heading = gettext_f(
236                        // Translators: Do NOT translate the content between '{' and '}',
237                        // this is a variable name.
238                        "No Messages Sent by {user}",
239                        &[("user", &member.display_name())],
240                    );
241                    let body = gettext_f(
242                        // Translators: Do NOT translate the content between '{' and '}',
243                        // this is a variable name.
244                        "There are no messages received from the homeserver sent by {user_id}. You can try to load more by going further back in the room history.",
245                        &[("user_id", member.user_id().as_str())],
246                    );
247                    (heading, body, None)
248                }
249            }
250        }
251    }
252}
253
254/// Show a dialog to confirm the given "destructive" action on the given room
255/// member.
256///
257/// Returns `None` if the user did not confirm.
258pub(crate) async fn confirm_room_member_destructive_action_dialog(
259    member: &Member,
260    action: RoomMemberDestructiveAction,
261    parent: &impl IsA<gtk::Widget>,
262) -> Option<ConfirmRoomMemberDestructiveActionResponse> {
263    let (heading, body, response) = action.dialog_content(member);
264
265    let child = gtk::Box::builder()
266        .orientation(gtk::Orientation::Vertical)
267        .spacing(12)
268        .build();
269
270    // Add an entry for the optional reason.
271    let reason_entry = adw::EntryRow::builder()
272        .title(gettext("Reason (optional)"))
273        .build();
274    let list_box = gtk::ListBox::builder()
275        .css_classes(["boxed-list"])
276        .margin_top(6)
277        .accessible_role(gtk::AccessibleRole::Group)
278        .build();
279    list_box.append(&reason_entry);
280    child.append(&list_box);
281
282    // Add a switch to ask the whether they want to also remove the latest events of
283    // the user.
284    let removable_events_count = if let RoomMemberDestructiveAction::Ban(count) = action {
285        count
286    } else {
287        0
288    };
289
290    let remove_events_switch = if removable_events_count > 0 {
291        let n = u32::try_from(removable_events_count).unwrap_or(u32::MAX);
292        let switch = adw::SwitchRow::builder()
293            .title(ngettext_f(
294                // Translators: Do NOT translate the content between '{' and '}',
295                // this is a variable name.
296                "Remove the latest message sent by the user",
297                "Remove the {n} latest messages sent by the user",
298                n,
299                &[("n", &n.to_string())],
300            ))
301            .build();
302
303        let list_box = gtk::ListBox::builder()
304            .css_classes(["boxed-list"])
305            .margin_top(6)
306            .accessible_role(gtk::AccessibleRole::Group)
307            .build();
308        list_box.append(&switch);
309        child.append(&list_box);
310
311        Some(switch)
312    } else {
313        None
314    };
315
316    // Ask for confirmation.
317    let confirm_dialog = adw::AlertDialog::builder()
318        .default_response("cancel")
319        .heading(heading)
320        .body(body)
321        .extra_child(&child)
322        .build();
323    confirm_dialog.add_responses(&[("cancel", &gettext("Cancel"))]);
324
325    if let Some(response) = response {
326        confirm_dialog.add_responses(&[("confirm", &response)]);
327        confirm_dialog.set_response_appearance("confirm", adw::ResponseAppearance::Destructive);
328    }
329
330    if confirm_dialog.choose_future(Some(parent)).await != "confirm" {
331        return None;
332    }
333
334    // Get the reason, and filter out if it is empty.
335    let reason = Some(reason_entry.text().trim().to_owned()).filter(|s| !s.is_empty());
336
337    let mut response = ConfirmRoomMemberDestructiveActionResponse {
338        reason,
339        ..Default::default()
340    };
341
342    if let Some(switch) = remove_events_switch {
343        response.remove_events = switch.is_active();
344    }
345
346    Some(response)
347}
348
349/// A response to the dialog to confirm a "destructive" action on a room
350/// member.
351#[derive(Debug, Default, Clone)]
352pub(crate) struct ConfirmRoomMemberDestructiveActionResponse {
353    /// The reason of the action.
354    pub reason: Option<String>,
355    /// Whether we can remove the events.
356    pub remove_events: bool,
357}
358
359/// Show a dialog to confirm muting one or several room members.
360pub(crate) async fn confirm_mute_room_member_dialog(
361    members: &[impl IsA<User>],
362    parent: &impl IsA<gtk::Widget>,
363) -> bool {
364    if members.is_empty() {
365        return false;
366    }
367
368    let first_member = members
369        .first()
370        .expect("there should be at least one member")
371        .upcast_ref();
372    let is_single_member = members.len() == 1;
373
374    // We don't use the count in the strings so we use separate gettext calls for
375    // singular and plural rather than using ngettext.
376    let heading = if is_single_member {
377        gettext_f(
378            // Translators: Do NOT translate the content between '{' and '}',
379            // this is a variable name.
380            "Mute {user}?",
381            &[("user", &first_member.display_name())],
382        )
383    } else {
384        gettext("Mute Members?")
385    };
386
387    // We don't use the count in the strings so we use separate gettext calls for
388    // singular and plural rather than using ngettext.
389    let body = if is_single_member {
390        gettext_f(
391            // Translators: Do NOT translate the content between '{' and '}',
392            // this is a variable name.
393            "Are you sure you want to mute {user_id}? They will not be able to send new messages to this room.",
394            &[("user_id", first_member.user_id().as_str())],
395        )
396    } else {
397        gettext(
398            "Are you sure you want to mute these members? They will not be able to send new messages to this room.",
399        )
400    };
401
402    // Ask for confirmation.
403    let confirm_dialog = adw::AlertDialog::builder()
404        .default_response("cancel")
405        .heading(heading)
406        .body(body)
407        .build();
408    confirm_dialog.add_responses(&[
409        ("cancel", &gettext("Cancel")),
410        // Translators: In this string, 'Mute' is a verb, as in 'Mute room member'.
411        ("mute", &gettext("Mute")),
412    ]);
413    confirm_dialog.set_response_appearance("mute", adw::ResponseAppearance::Destructive);
414
415    confirm_dialog.choose_future(Some(parent)).await == "mute"
416}
417
418/// Show a dialog to confirm setting the power level of one or several room
419/// members with the same value as our own.
420pub(crate) async fn confirm_set_room_member_power_level_same_as_own_dialog(
421    members: &[impl IsA<User>],
422    parent: &impl IsA<gtk::Widget>,
423) -> bool {
424    if members.is_empty() {
425        return false;
426    }
427
428    let first_member = members
429        .first()
430        .expect("there should be at least one member")
431        .upcast_ref();
432    let is_single_member = members.len() == 1;
433
434    // We don't use the count in the strings so we use separate gettext calls for
435    // singular and plural rather than using ngettext.
436    let heading = if is_single_member {
437        gettext_f(
438            // Translators: Do NOT translate the content between '{' and '}',
439            // this is a variable name.
440            "Promote {user}?",
441            &[("user", &first_member.display_name())],
442        )
443    } else {
444        gettext("Promote Members?")
445    };
446
447    // We don't use the count in the strings so we use separate gettext calls for
448    // singular and plural rather than using ngettext.
449    let body = if is_single_member {
450        gettext_f(
451            // Translators: Do NOT translate the content between '{' and '}',
452            // this is a variable name. The count cannot be zero.
453            "If you promote {user_id} to the same level as yours, you will not be able to demote them in the future.",
454            &[("user_id", first_member.user_id().as_str())],
455        )
456    } else {
457        gettext(
458            "If you promote these members to the same level as yours, you will not be able to demote them in the future.",
459        )
460    };
461
462    // Ask for confirmation.
463    let confirm_dialog = adw::AlertDialog::builder()
464        .default_response("cancel")
465        .heading(heading)
466        .body(body)
467        .build();
468    confirm_dialog.add_responses(&[
469        ("cancel", &gettext("Cancel")),
470        ("promote", &gettext("Promote")),
471    ]);
472    confirm_dialog.set_response_appearance("promote", adw::ResponseAppearance::Destructive);
473
474    confirm_dialog.choose_future(Some(parent)).await == "promote"
475}
476
477/// Show a dialog to confirm the demotion of our own user.
478pub(crate) async fn confirm_own_demotion_dialog(parent: &impl IsA<gtk::Widget>) -> bool {
479    let heading = gettext("Demote Yourself?");
480    let body = gettext(
481        "Are you sure you want to lower your power level? You will need to ask another member with a higher power level to undo this.",
482    );
483
484    // Ask for confirmation.
485    let confirm_dialog = adw::AlertDialog::builder()
486        .default_response("cancel")
487        .heading(heading)
488        .body(body)
489        .build();
490    confirm_dialog.add_responses(&[
491        ("cancel", &gettext("Cancel")),
492        ("demote", &gettext("Demote")),
493    ]);
494    confirm_dialog.set_response_appearance("demote", adw::ResponseAppearance::Destructive);
495
496    confirm_dialog.choose_future(Some(parent)).await == "demote"
497}
498
499/// Show a dialog for the user to choose what to do about unsaved changes.
500pub(crate) async fn unsaved_changes_dialog(
501    parent: &impl IsA<gtk::Widget>,
502) -> UnsavedChangesResponse {
503    let title = gettext("Save Changes?");
504    let description =
505        gettext("This page contains unsaved changes. Changes which are not saved will be lost.");
506    let dialog = adw::AlertDialog::builder()
507        .title(title)
508        .body(description)
509        .default_response("cancel")
510        .build();
511
512    dialog.add_responses(&[
513        ("cancel", &gettext("Cancel")),
514        ("discard", &gettext("Discard")),
515        ("save", &gettext("Save")),
516    ]);
517    dialog.set_response_appearance("discard", adw::ResponseAppearance::Destructive);
518    dialog.set_response_appearance("save", adw::ResponseAppearance::Suggested);
519
520    match dialog.choose_future(Some(parent)).await.as_str() {
521        "discard" => UnsavedChangesResponse::Discard,
522        "save" => UnsavedChangesResponse::Save,
523        _ => UnsavedChangesResponse::Cancel,
524    }
525}
526
527/// A response to the dialog to choose what to do about unsaved changes.
528#[derive(Debug, Clone, Copy, PartialEq, Eq)]
529pub(crate) enum UnsavedChangesResponse {
530    /// Save the changes.
531    Save,
532    /// Discard the changes.
533    Discard,
534    /// Cancel the current action.
535    Cancel,
536}