mirror of
https://github.com/flectar/mail.git
synced 2026-11-11 17:57:54 +00:00
feat: block senders from the reader
This commit is contained in:
1 parent
1912158279
commit
14bb459f94
16 files changed
+1845
-1160
No files matched your search
+1222
-1153
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,222 @@
|
||||
//! Blocking the sender of the open message (#227). A dialog asks first;
|
||||
//! their mail in an inbox then moves to Spam, with Undo, and sync sends what
|
||||
//! they mail later there too (`Core::block_sender`). Settings lists the
|
||||
//! blocked addresses.
|
||||
|
||||
use super::*;
|
||||
use std::future::Future;
|
||||
|
||||
struct Controller {
|
||||
app: slint::Weak<AppWindow>,
|
||||
state: Rc<RefCell<InboxState>>,
|
||||
runtime: tokio::runtime::Handle,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static CONTROLLER: RefCell<Option<Rc<Controller>>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
fn with_controller(f: impl FnOnce(&Rc<Controller>)) {
|
||||
let controller = CONTROLLER.with(|slot| slot.borrow().clone());
|
||||
if let Some(controller) = controller {
|
||||
f(&controller);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn install(
|
||||
app: &AppWindow,
|
||||
state: &Rc<RefCell<InboxState>>,
|
||||
runtime: &tokio::runtime::Handle,
|
||||
) {
|
||||
CONTROLLER.with(|slot| {
|
||||
*slot.borrow_mut() = Some(Rc::new(Controller {
|
||||
app: app.as_weak(),
|
||||
state: Rc::clone(state),
|
||||
runtime: runtime.clone(),
|
||||
}));
|
||||
});
|
||||
let ui = app.global::<BlockSender>();
|
||||
ui.on_start(|| with_controller(|controller| controller.start()));
|
||||
ui.on_confirm(|| with_controller(|controller| controller.confirm()));
|
||||
ui.on_close(|| with_controller(|controller| controller.close()));
|
||||
ui.on_unblock(|address| {
|
||||
with_controller(|controller| controller.unblock(address.to_string(), false));
|
||||
});
|
||||
}
|
||||
|
||||
/// Show the blocked addresses in Settings, once the core is there.
|
||||
pub(super) fn refresh() {
|
||||
with_controller(|controller| controller.refresh_list());
|
||||
}
|
||||
|
||||
/// Undo took a block back.
|
||||
pub(super) fn unblocked(address: &str) {
|
||||
with_controller(|controller| controller.changed(address, false));
|
||||
}
|
||||
|
||||
impl Controller {
|
||||
fn core(&self) -> Option<CoreMailSource> {
|
||||
self.state.borrow().core.clone()
|
||||
}
|
||||
|
||||
/// The menu: ask before blocking the sender of the open message, or
|
||||
/// unblock them.
|
||||
fn start(&self) {
|
||||
let Some(app) = self.app.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let ui = app.global::<BlockSender>();
|
||||
if !ui.get_stage().is_empty() || !ui.get_available() {
|
||||
return;
|
||||
}
|
||||
let address = app.get_selected_address().to_string();
|
||||
if ui.get_blocked() {
|
||||
self.unblock(address, true);
|
||||
return;
|
||||
}
|
||||
let Some(core) = self.core() else {
|
||||
return;
|
||||
};
|
||||
let name = app.get_selected_sender().to_string();
|
||||
self.spawn(
|
||||
async move { core.block_offer(&address).await },
|
||||
move |app, result| match result {
|
||||
Ok(Some(offer)) => {
|
||||
let ui = app.global::<BlockSender>();
|
||||
// A sender known only by address is named by it once.
|
||||
let name = if name.eq_ignore_ascii_case(&offer.address) {
|
||||
String::new()
|
||||
} else {
|
||||
name
|
||||
};
|
||||
ui.set_name(name.into());
|
||||
ui.set_address(offer.address.into());
|
||||
ui.set_inbox_count(i32::try_from(offer.inbox_count).unwrap_or(i32::MAX));
|
||||
ui.set_stage("confirm".into());
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(error) => {
|
||||
crate::undo::tell(&app, UiMessage::detail("Could not block: {}", error))
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// The dialog's Block button.
|
||||
fn confirm(&self) {
|
||||
let Some(app) = self.app.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let ui = app.global::<BlockSender>();
|
||||
if ui.get_stage() != "confirm" {
|
||||
return;
|
||||
}
|
||||
let Some(core) = self.core() else {
|
||||
return;
|
||||
};
|
||||
let address = ui.get_address().to_string();
|
||||
ui.set_stage("working".into());
|
||||
self.spawn(
|
||||
async move { core.block_sender(&address).await },
|
||||
|app, result| {
|
||||
app.global::<BlockSender>().set_stage("".into());
|
||||
match result {
|
||||
Ok(blocked) => {
|
||||
with_controller(|controller| controller.changed(&blocked.address, true));
|
||||
crate::undo::blocked(&app, blocked.address, blocked.action_ids);
|
||||
}
|
||||
Err(error) => {
|
||||
crate::undo::tell(&app, UiMessage::detail("Could not block: {}", error))
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn close(&self) {
|
||||
if let Some(app) = self.app.upgrade() {
|
||||
let ui = app.global::<BlockSender>();
|
||||
if ui.get_stage() != "working" {
|
||||
ui.set_stage("".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings, or the menu of a blocked sender's message: stop blocking
|
||||
/// `address`, and say so when asked from a message.
|
||||
fn unblock(&self, address: String, tell: bool) {
|
||||
let Some(core) = self.core() else {
|
||||
return;
|
||||
};
|
||||
self.spawn(
|
||||
async move { core.unblock_sender(&address).await.map(|()| address) },
|
||||
move |app, result| match result {
|
||||
Ok(address) => {
|
||||
with_controller(|controller| controller.changed(&address, false));
|
||||
if tell {
|
||||
crate::undo::tell(&app, UiMessage::detail("Unblocked {}.", address));
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
crate::undo::tell(&app, UiMessage::detail("Could not unblock: {}", error))
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// The block list changed: the open message's menu, the loaded rows from
|
||||
/// that sender, and Settings follow.
|
||||
fn changed(&self, address: &str, blocked: bool) {
|
||||
if let Some(app) = self.app.upgrade()
|
||||
&& app.get_selected_address().eq_ignore_ascii_case(address)
|
||||
{
|
||||
app.global::<BlockSender>().set_blocked(blocked);
|
||||
}
|
||||
for message in self.state.borrow_mut().messages.iter_mut() {
|
||||
if message.address.eq_ignore_ascii_case(address) {
|
||||
message.sender_blocked = blocked;
|
||||
}
|
||||
}
|
||||
self.refresh_list();
|
||||
}
|
||||
|
||||
fn refresh_list(&self) {
|
||||
let Some(core) = self.core() else {
|
||||
return;
|
||||
};
|
||||
self.spawn(
|
||||
async move { core.blocked_addresses().await },
|
||||
|app, result| {
|
||||
if let Ok(addresses) = result {
|
||||
let addresses = addresses
|
||||
.into_iter()
|
||||
.map(slint::SharedString::from)
|
||||
.collect::<Vec<_>>();
|
||||
app.global::<BlockSender>()
|
||||
.set_addresses(ModelRc::new(VecModel::from(addresses)));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Run `work` on the runtime and `apply` its result on the UI thread.
|
||||
fn spawn<T: Send + 'static>(
|
||||
&self,
|
||||
work: impl Future<Output = Result<T, String>> + Send + 'static,
|
||||
apply: impl FnOnce(AppWindow, Result<T, String>) + 'static,
|
||||
) {
|
||||
let weak = self.app.clone();
|
||||
let task = self.runtime.spawn(work);
|
||||
let abort = task.abort_handle();
|
||||
if slint::spawn_local(async move {
|
||||
let result = task.await.unwrap_or_else(|error| Err(error.to_string()));
|
||||
if let Some(app) = weak.upgrade() {
|
||||
apply(app, result);
|
||||
}
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
abort.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
+52
-7
@@ -6,13 +6,14 @@ use flectar_mail_core::{
|
||||
events::CoreEvent,
|
||||
models::{
|
||||
Account, AccountConfig, ActionKind, ActionParams, ActionTarget, AddPasswordAccountArgs,
|
||||
Address, CalendarConnection, CalendarEvent, CardDavConnection, ConnectCaldavArgs,
|
||||
ConnectCardDavArgs, ContactRecordCursor, ContactRecordPage, CreateEventArgs, CustomTheme,
|
||||
DraftAttachmentIn, FolderInfo, Label, MailGrouping, MailHistory, MailProfile, MailProtocol,
|
||||
MailboxBadgeCounts, MessageCursor, MessageDetail, MessageSearchCursor, MessageSummary,
|
||||
PerformActionArgs, PortableAccountConfig, Provider, QueueSendArgs, QueueSendResult,
|
||||
SaveDraftArgs, SearchCursor, Settings, Snippet, ThreadCursor, ThreadSummary,
|
||||
UnsubscribeOffer, UnsubscribeOutcome, View, normalized_workspace_list_pane_width, roles,
|
||||
Address, BlockOffer, BlockResult, CalendarConnection, CalendarEvent, CardDavConnection,
|
||||
ConnectCaldavArgs, ConnectCardDavArgs, ContactRecordCursor, ContactRecordPage,
|
||||
CreateEventArgs, CustomTheme, DraftAttachmentIn, FolderInfo, Label, MailGrouping,
|
||||
MailHistory, MailProfile, MailProtocol, MailboxBadgeCounts, MessageCursor, MessageDetail,
|
||||
MessageSearchCursor, MessageSummary, PerformActionArgs, PortableAccountConfig, Provider,
|
||||
QueueSendArgs, QueueSendResult, SaveDraftArgs, SearchCursor, Settings, Snippet,
|
||||
ThreadCursor, ThreadSummary, UnsubscribeOffer, UnsubscribeOutcome, View,
|
||||
normalized_workspace_list_pane_width, roles,
|
||||
},
|
||||
};
|
||||
#[cfg(test)]
|
||||
@@ -90,6 +91,10 @@ pub struct MailMessage {
|
||||
/// The message names a way to leave the mailing list it came from, and
|
||||
/// is neither one's own nor in the spam folder.
|
||||
pub can_unsubscribe: bool,
|
||||
/// Someone else sent the message, from an address that can be blocked.
|
||||
pub can_block: bool,
|
||||
/// Its sender is blocked: their mail goes to Spam.
|
||||
pub sender_blocked: bool,
|
||||
/// The one-time code the message asks its reader to enter.
|
||||
pub verification_code: Option<String>,
|
||||
/// Gmail labels are tags; other accounts file labels like folders.
|
||||
@@ -133,6 +138,8 @@ impl MailMessage {
|
||||
body_pending: false,
|
||||
sender_verification: String::new(),
|
||||
can_unsubscribe: false,
|
||||
can_block: false,
|
||||
sender_blocked: false,
|
||||
verification_code: None,
|
||||
account_uses_labels: false,
|
||||
first_attachment: None,
|
||||
@@ -1876,6 +1883,38 @@ impl CoreMailSource {
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
/// `address` as it would be blocked, for the confirmation first.
|
||||
pub async fn block_offer(&self, address: &str) -> Result<Option<BlockOffer>, String> {
|
||||
self.core
|
||||
.block_offer(address)
|
||||
.await
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
/// Block `address`; what moved to Spam, for Undo.
|
||||
pub async fn block_sender(&self, address: &str) -> Result<BlockResult, String> {
|
||||
self.core
|
||||
.block_sender(address)
|
||||
.await
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
pub async fn unblock_sender(&self, address: &str) -> Result<(), String> {
|
||||
self.core
|
||||
.unblock_sender(address)
|
||||
.await
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
/// Every blocked address, alphabetically.
|
||||
pub async fn blocked_addresses(&self) -> Result<Vec<String>, String> {
|
||||
self.core
|
||||
.blocked_senders()
|
||||
.await
|
||||
.map(|senders| senders.into_iter().map(|sender| sender.address).collect())
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
/// Take back the actions of one gesture. False when none could be.
|
||||
pub async fn undo_actions(&self, action_ids: Vec<i64>) -> Result<bool, String> {
|
||||
self.core
|
||||
@@ -3039,6 +3078,8 @@ fn summary_to_message(
|
||||
body_pending: true,
|
||||
sender_verification: String::new(),
|
||||
can_unsubscribe: false,
|
||||
can_block: false,
|
||||
sender_blocked: false,
|
||||
verification_code: thread.verification_code,
|
||||
account_uses_labels: thread_account
|
||||
.is_some_and(|account| account.mail_protocol == MailProtocol::Gmail),
|
||||
@@ -3145,6 +3186,8 @@ fn message_summary_to_message(
|
||||
body_pending: true,
|
||||
sender_verification: String::new(),
|
||||
can_unsubscribe: false,
|
||||
can_block: false,
|
||||
sender_blocked: false,
|
||||
verification_code: message.verification_code,
|
||||
account_uses_labels: message_account
|
||||
.is_some_and(|account| account.mail_protocol == MailProtocol::Gmail),
|
||||
@@ -3261,6 +3304,8 @@ fn detail_to_message(row: &MailMessage, message: &MessageDetail, body_loaded: bo
|
||||
&& message.list_unsubscribe.as_deref().is_some_and(|header| {
|
||||
flectar_mail_core::unsubscribe::plan(header, false).is_some()
|
||||
}),
|
||||
can_block: !message.is_draft && !message.is_outgoing && message.from.email.contains('@'),
|
||||
sender_blocked: message.sender_blocked,
|
||||
verification_code: row.verification_code.clone(),
|
||||
account_uses_labels: row.account_uses_labels,
|
||||
first_attachment: row.first_attachment.clone(),
|
||||
|
||||
@@ -120,6 +120,9 @@ pub(super) fn clear_reader_projection(app: &AppWindow) {
|
||||
app.global::<MessageColors>().set_original(false);
|
||||
app.global::<AccountMailPreferences>().invoke_close_reader();
|
||||
app.global::<Unsubscribe>().set_available(false);
|
||||
let block = app.global::<BlockSender>();
|
||||
block.set_available(false);
|
||||
block.set_blocked(false);
|
||||
let reader = app.global::<EmailReader>();
|
||||
reader.set_available(false);
|
||||
reader.set_body_pending(false);
|
||||
@@ -160,6 +163,9 @@ pub(super) fn apply_email(
|
||||
app.set_selected_sender_verification(email.sender_verification.clone().into());
|
||||
app.global::<Unsubscribe>()
|
||||
.set_available(email.can_unsubscribe);
|
||||
let block = app.global::<BlockSender>();
|
||||
block.set_available(email.can_block);
|
||||
block.set_blocked(email.sender_blocked);
|
||||
let reader = app.global::<EmailReader>();
|
||||
reader.set_body_pending(email.body_pending);
|
||||
reader.set_authored_text(email.text.clone().unwrap_or_default().into());
|
||||
|
||||
@@ -1872,6 +1872,8 @@ mod tests {
|
||||
body_pending: true,
|
||||
sender_verification: String::new(),
|
||||
can_unsubscribe: false,
|
||||
can_block: false,
|
||||
sender_blocked: false,
|
||||
verification_code: None,
|
||||
account_uses_labels: false,
|
||||
first_attachment: None,
|
||||
|
||||
@@ -7,6 +7,7 @@ mod app_runtime;
|
||||
mod attachment_controller;
|
||||
#[cfg(desktop)]
|
||||
mod autostart;
|
||||
mod block_sender;
|
||||
mod browser;
|
||||
pub use browser::ExternalUrlOpener;
|
||||
mod calendar;
|
||||
@@ -4538,6 +4539,7 @@ pub fn run(platform: PlatformContext) -> Result<(), Box<dyn std::error::Error>>
|
||||
app.set_startup_hydrated(true);
|
||||
app.set_startup_ready(true);
|
||||
mailto_links::open_pending_soon(&app);
|
||||
block_sender::refresh();
|
||||
app.invoke_product_changed(app.get_active_view());
|
||||
app.global::<TabsUi>().invoke_restore_active();
|
||||
app.set_startup_failed(false);
|
||||
@@ -6093,6 +6095,7 @@ pub fn run(platform: PlatformContext) -> Result<(), Box<dyn std::error::Error>>
|
||||
mailto_links::install(&app, composers);
|
||||
undo::install(&app, &state, &runtime);
|
||||
unsubscribe::install(&app, &state, &runtime, platform.external_urls.clone());
|
||||
block_sender::install(&app, &state, &runtime);
|
||||
#[cfg(target_os = "macos")]
|
||||
mailto_links::receive_from_macos(&app);
|
||||
notifications::ui::attach(&app, &state, &runtime, platform.notifications.clone());
|
||||
|
||||
@@ -142,6 +142,8 @@ impl From<WarmStartMessage> for mail::MailMessage {
|
||||
sender_verification: message.sender_verification,
|
||||
// The banner waits for the message itself.
|
||||
can_unsubscribe: false,
|
||||
can_block: false,
|
||||
sender_blocked: false,
|
||||
verification_code: None,
|
||||
account_uses_labels: message.account_uses_labels,
|
||||
first_attachment: None,
|
||||
@@ -1270,6 +1272,8 @@ mod warm_start_tests {
|
||||
body_pending: false,
|
||||
sender_verification: "domain".into(),
|
||||
can_unsubscribe: false,
|
||||
can_block: false,
|
||||
sender_blocked: false,
|
||||
verification_code: Some("48291375".into()),
|
||||
account_uses_labels: true,
|
||||
first_attachment: Some(flectar_mail_core::models::AttachmentMeta {
|
||||
|
||||
+66
@@ -34,6 +34,11 @@ enum Shown {
|
||||
Send { action_id: i64, draft_id: i64 },
|
||||
/// The actions of one archive, move, deletion or change of marks.
|
||||
Gesture { action_ids: Vec<i64> },
|
||||
/// A sender just blocked, and the moves of their inbox mail to Spam.
|
||||
Block {
|
||||
address: String,
|
||||
action_ids: Vec<i64>,
|
||||
},
|
||||
/// News with nothing to take back.
|
||||
Note,
|
||||
}
|
||||
@@ -136,6 +141,24 @@ pub(super) fn gesture(app: &AppWindow, action_ids: Vec<i64>, batch: UndoableBatc
|
||||
});
|
||||
}
|
||||
|
||||
/// A sender was just blocked: tell of it, with Undo, which unblocks them
|
||||
/// and puts their mail back.
|
||||
pub(super) fn blocked(app: &AppWindow, address: String, action_ids: Vec<i64>) {
|
||||
with_undo(|undo| {
|
||||
let message = UiMessage::detail("Blocked {}.", &address);
|
||||
undo.show(
|
||||
app,
|
||||
Shown::Block {
|
||||
address,
|
||||
action_ids,
|
||||
},
|
||||
message,
|
||||
"undo",
|
||||
Some(GESTURE_NOTICE_FOR),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Tell of something done that cannot be taken back here, such as leaving
|
||||
/// a mailing list.
|
||||
pub(super) fn tell(app: &AppWindow, message: UiMessage) {
|
||||
@@ -255,6 +278,13 @@ impl Undo {
|
||||
},
|
||||
) => self.undo_send(&app, action_id, draft_id),
|
||||
("undo", Shown::Gesture { action_ids }) => self.undo_gesture(&app, action_ids),
|
||||
(
|
||||
"undo",
|
||||
Shown::Block {
|
||||
address,
|
||||
action_ids,
|
||||
},
|
||||
) => self.undo_block(&app, address, action_ids),
|
||||
("open-draft", Shown::Send { draft_id, .. }) => {
|
||||
self.hide_notice();
|
||||
composer::reopen_draft(draft_id);
|
||||
@@ -329,6 +359,42 @@ impl Undo {
|
||||
}
|
||||
}
|
||||
|
||||
fn undo_block(self: &Rc<Self>, app: &AppWindow, address: String, action_ids: Vec<i64>) {
|
||||
let Some(core) = self.state.borrow().core.clone() else {
|
||||
return;
|
||||
};
|
||||
let shown = Shown::Block {
|
||||
address: address.clone(),
|
||||
action_ids: action_ids.clone(),
|
||||
};
|
||||
let unblocked = self.runtime.block_on(core.unblock_sender(&address));
|
||||
if unblocked.is_ok() {
|
||||
crate::block_sender::unblocked(&address);
|
||||
}
|
||||
let result = match unblocked {
|
||||
Err(error) => Err(error),
|
||||
Ok(()) if action_ids.is_empty() => Ok(true),
|
||||
Ok(()) => self.runtime.block_on(core.undo_actions(action_ids)),
|
||||
};
|
||||
match result {
|
||||
Ok(true) => self.hide_notice(),
|
||||
Ok(false) => self.show(
|
||||
app,
|
||||
shown,
|
||||
UiMessage::plain("This can no longer be undone."),
|
||||
"",
|
||||
Some(ANSWER_NOTICE_FOR),
|
||||
),
|
||||
Err(error) => self.show(
|
||||
app,
|
||||
shown,
|
||||
UiMessage::detail("Could not undo: {}", error),
|
||||
"",
|
||||
Some(ANSWER_NOTICE_FOR),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// A message left, or could not.
|
||||
fn finished(self: &Rc<Self>, action_id: i64, result: Result<(), String>) {
|
||||
let ended = {
|
||||
|
||||
@@ -12,6 +12,7 @@ export { SystemNotifications } from "system-notifications.slint";
|
||||
export { Updates } from "updates.slint";
|
||||
export { Notice } from "notice.slint";
|
||||
export { Unsubscribe } from "unsubscribe.slint";
|
||||
export { BlockSender } from "block-sender.slint";
|
||||
import { AppIcon, AppIconVariant } from "app-icon.slint";
|
||||
import { WindowChrome, WindowControls, WindowDragArea } from "window-chrome.slint";
|
||||
// Bundled once at compile time and used throughout the application chrome.
|
||||
@@ -42,6 +43,8 @@ import { NoticeToast } from "components/notice-toast.slint";
|
||||
import { Notice } from "notice.slint";
|
||||
import { UnsubscribeDialog } from "dialogs/unsubscribe-dialog.slint";
|
||||
import { Unsubscribe } from "unsubscribe.slint";
|
||||
import { BlockSenderDialog } from "dialogs/block-sender-dialog.slint";
|
||||
import { BlockSender } from "block-sender.slint";
|
||||
import { BoundComposer, ComposerUi } from "components/composer-ui.slint";
|
||||
import { ComposerText } from "components/composer-fields.slint";
|
||||
export { ComposerUi } from "components/composer-ui.slint";
|
||||
@@ -592,6 +595,10 @@ export global I18n {
|
||||
if (key == "Could not unsubscribe: {}") { return @tr("Could not unsubscribe: {}", argument); }
|
||||
if (key == "Unsubscribed from {}.") { return @tr("Unsubscribed from {}.", argument); }
|
||||
if (key == "Unsubscribe request sent to {}.") { return @tr("Unsubscribe request sent to {}.", argument); }
|
||||
if (key == "Blocked {}.") { return @tr("Blocked {}.", argument); }
|
||||
if (key == "Unblocked {}.") { return @tr("Unblocked {}.", argument); }
|
||||
if (key == "Could not block: {}") { return @tr("Could not block: {}", argument); }
|
||||
if (key == "Could not unblock: {}") { return @tr("Could not unblock: {}", argument); }
|
||||
if (key == "Moved {} messages to the other account.") { return @tr("Moved one message to the other account." | "Moved {0} messages to the other account." % round(argument.to-float()), argument); }
|
||||
if (key == "Copied {} messages to the other account.") { return @tr("Copied one message to the other account." | "Copied {0} messages to the other account." % round(argument.to-float()), argument); }
|
||||
if (key == "Moved or copied {} messages to the other account.") { return @tr("Moved or copied one message to the other account." | "Moved or copied {0} messages to the other account." % round(argument.to-float()), argument); }
|
||||
@@ -2326,6 +2333,14 @@ export component AppWindow inherits Window {
|
||||
z: 16000;
|
||||
}
|
||||
|
||||
if BlockSender.stage != "" : BlockSenderDialog {
|
||||
x: root.content-x;
|
||||
y: root.content-y;
|
||||
width: root.content-width;
|
||||
height: root.content-height;
|
||||
z: 16000;
|
||||
}
|
||||
|
||||
if root.trash-delete-open : ConfirmationDialog {
|
||||
x: root.content-x;
|
||||
y: root.content-y;
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// Blocking the sender of the open message, whose mail then goes to Spam
|
||||
// (src/block_sender.rs).
|
||||
export global BlockSender {
|
||||
// The open message has a sender who can be blocked.
|
||||
in property <bool> available;
|
||||
// Its sender is blocked already.
|
||||
in property <bool> blocked;
|
||||
// Where the dialog is: "" (closed), "confirm" or "working".
|
||||
in property <string> stage;
|
||||
// Whom the dialog is about.
|
||||
in property <string> name;
|
||||
in property <string> address;
|
||||
// How many of their messages are in an inbox now.
|
||||
in property <int> inbox-count;
|
||||
// Every blocked address, for Settings.
|
||||
in property <[string]> addresses;
|
||||
// The menu: ask before blocking the open message's sender, or unblock
|
||||
// them.
|
||||
callback start();
|
||||
callback confirm();
|
||||
callback close();
|
||||
// Settings: stop blocking this address.
|
||||
callback unblock(string);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { VerticalBox, HorizontalBox } from "std-widgets.slint";
|
||||
import { AppTheme } from "../design-system.slint";
|
||||
import { Button } from "../components/button.slint";
|
||||
import { TypeScale } from "../typography.slint";
|
||||
import { BlockSender } from "../block-sender.slint";
|
||||
|
||||
// Asks before blocking the sender of the open message: their mail goes to
|
||||
// Spam from then on, and what is in an inbox now moves there
|
||||
// (src/block_sender.rs).
|
||||
export component BlockSenderDialog inherits Rectangle {
|
||||
private property <bool> narrow: panel.width < 360px;
|
||||
private property <bool> working: BlockSender.stage == "working";
|
||||
private property <string> title: @tr("Block {}?", BlockSender.name != "" ? BlockSender.name : BlockSender.address);
|
||||
|
||||
visible: BlockSender.stage != "";
|
||||
background: #00000088;
|
||||
z: 1000;
|
||||
accessible-role: groupbox;
|
||||
accessible-label: root.title;
|
||||
|
||||
function cancel() {
|
||||
if !root.working {
|
||||
BlockSender.close();
|
||||
}
|
||||
}
|
||||
|
||||
changed visible => {
|
||||
if self.visible {
|
||||
dialog-focus.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// Consume pointer input over the scrim.
|
||||
TouchArea { }
|
||||
|
||||
panel := Rectangle {
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
width: min(440px, parent.width - 32px);
|
||||
height: max(172px, dialog-content.preferred-height);
|
||||
border-radius: 12px;
|
||||
border-width: 1px;
|
||||
border-color: AppTheme.border;
|
||||
background: AppTheme.surface;
|
||||
drop-shadow-blur: 24px;
|
||||
drop-shadow-offset-y: 8px;
|
||||
drop-shadow-color: #00000055;
|
||||
|
||||
dialog-focus := FocusScope {
|
||||
key-pressed(event) => {
|
||||
if event.text == Key.Escape {
|
||||
root.cancel();
|
||||
return accept;
|
||||
}
|
||||
reject
|
||||
}
|
||||
|
||||
dialog-content := VerticalBox {
|
||||
padding-top: 22px;
|
||||
padding-right: 22px;
|
||||
padding-bottom: 20px;
|
||||
padding-left: 22px;
|
||||
spacing: 12px;
|
||||
|
||||
HorizontalBox {
|
||||
height: max(root.narrow ? 56px : 42px, dialog-title.preferred-height + 16px);
|
||||
padding: 0px;
|
||||
spacing: 12px;
|
||||
Rectangle {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 21px;
|
||||
background: AppTheme.accent-soft;
|
||||
Image {
|
||||
x: 11px;
|
||||
y: 11px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
source: @image-url("../icons/lucide/outline/ban.svg");
|
||||
colorize: AppTheme.accent;
|
||||
image-fit: contain;
|
||||
accessible-role: none;
|
||||
}
|
||||
}
|
||||
dialog-title := Text {
|
||||
text: root.title;
|
||||
color: AppTheme.title;
|
||||
font-size: TypeScale.heading;
|
||||
font-weight: 700;
|
||||
vertical-alignment: center;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
height: max(24px, self.preferred-height);
|
||||
text: @tr("Future mail from {} will go to Spam.", BlockSender.address);
|
||||
color: AppTheme.body;
|
||||
font-size: TypeScale.secondary;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
if BlockSender.inbox-count > 0 : Text {
|
||||
height: self.preferred-height;
|
||||
text: @tr("Their message in your inbox moves there now." | "Their {n} messages in your inbox move there now." % BlockSender.inbox-count);
|
||||
color: AppTheme.muted;
|
||||
font-size: TypeScale.caption;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
Rectangle { vertical-stretch: 1; }
|
||||
|
||||
// Wide panels put the buttons in a row, the main one last;
|
||||
// narrow ones stack them, the main one first.
|
||||
GridLayout {
|
||||
spacing: 8px;
|
||||
Button {
|
||||
row: root.narrow ? 1 : 0;
|
||||
col: 0;
|
||||
height: 34px;
|
||||
enabled: !root.working;
|
||||
text: @tr("Cancel");
|
||||
clicked => { BlockSender.close(); }
|
||||
}
|
||||
Button {
|
||||
row: 0;
|
||||
col: root.narrow ? 0 : 1;
|
||||
height: 34px;
|
||||
primary: true;
|
||||
enabled: !root.working;
|
||||
text: root.working ? @tr("Blocking…") : @tr("Block");
|
||||
clicked => { BlockSender.confirm(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import { CompactPageHeader, WorkspaceSidebarSurface } from "../components/worksp
|
||||
import { TabSwitcherButton } from "../components/tab-overview.slint";
|
||||
import { LocalMailUi } from "../local-mail.slint";
|
||||
import { Numbers } from "../numbers.slint";
|
||||
import { BlockSender } from "../block-sender.slint";
|
||||
|
||||
// Use the same focusable controls in both account-card layouts.
|
||||
component AccountActions inherits HorizontalLayout {
|
||||
@@ -1900,6 +1901,53 @@ export component SettingsDialog inherits Rectangle {
|
||||
}
|
||||
}
|
||||
}
|
||||
Rectangle {
|
||||
height: max(112px, blocked-senders.preferred-height);
|
||||
background: root.surface;
|
||||
border-radius: 8px;
|
||||
border-width: 1px;
|
||||
border-color: root.border-subtle;
|
||||
blocked-senders := VerticalBox {
|
||||
padding: 18px;
|
||||
spacing: 8px;
|
||||
Text { text: @tr("Blocked senders"); color: root.heading; font-size: TypeScale.subtitle; font-weight: 700; wrap: word-wrap; }
|
||||
Text {
|
||||
height: self.preferred-height;
|
||||
text: @tr("Their mail goes to Spam when Flectar Mail receives it. Block a sender from the menu of one of their messages.");
|
||||
color: root.faint-text;
|
||||
font-size: TypeScale.caption;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
if BlockSender.addresses.length == 0 : Text {
|
||||
text: @tr("No one is blocked.");
|
||||
color: root.secondary-text;
|
||||
font-size: TypeScale.secondary;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
for address in BlockSender.addresses : HorizontalBox {
|
||||
height: 36px;
|
||||
// The buttons line up with the card's other buttons.
|
||||
padding: 0px;
|
||||
padding-right: 8px;
|
||||
spacing: 8px;
|
||||
Text {
|
||||
text: address;
|
||||
color: root.body-text;
|
||||
font-size: TypeScale.secondary;
|
||||
vertical-alignment: center;
|
||||
overflow: elide;
|
||||
horizontal-stretch: 1;
|
||||
}
|
||||
Button {
|
||||
width: max(96px, self.content-width);
|
||||
horizontal-stretch: 0;
|
||||
text: @tr("Unblock");
|
||||
accessible-label: @tr("Unblock {}", address);
|
||||
clicked => { BlockSender.unblock(address); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if root.sync_status != "" : CopyableDiagnostic {
|
||||
text: root.sync_status;
|
||||
text-color: root.secondary-text;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<!-- Generated display outline; originals and license in ../sources/ and ../LICENSE. -->
|
||||
<!-- @license lucide-static v1.46.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-ban"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M4.929 4.929 19.07 19.071" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 450 B |
@@ -9,6 +9,9 @@
|
||||
"arrow-left.svg": {
|
||||
"source": "arrow-left"
|
||||
},
|
||||
"ban.svg": {
|
||||
"source": "ban"
|
||||
},
|
||||
"bold.svg": {
|
||||
"source": "bold"
|
||||
},
|
||||
@@ -230,6 +233,10 @@
|
||||
"path": "sources/arrow-left.svg",
|
||||
"sha256": "bef90f9aa435042faf0b71d95e7f9da0f2dff2bb026cafaca2f506aee4c45c03"
|
||||
},
|
||||
"ban": {
|
||||
"path": "sources/ban.svg",
|
||||
"sha256": "7cdacf6d52ee1b4da0406b62bb738c9ff2c2acc98f097ff2916534887fcf280c"
|
||||
},
|
||||
"bell": {
|
||||
"path": "sources/bell.svg",
|
||||
"sha256": "5a53e47f042acc608da90edca964a918e656814dab4927c19051542c84df3fd0"
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<!-- @license lucide-static v1.46.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-ban"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M4.929 4.929 19.07 19.071" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 359 B |
@@ -21,6 +21,7 @@ import { WindowChrome, WindowControlsClearance } from "../window-chrome.slint";
|
||||
import { Shortcuts } from "../shortcuts.slint";
|
||||
import { LocalMailUi } from "../local-mail.slint";
|
||||
import { Unsubscribe } from "../unsubscribe.slint";
|
||||
import { BlockSender } from "../block-sender.slint";
|
||||
|
||||
component ReaderControl inherits ToolbarIconButton {
|
||||
tooltip-above: true;
|
||||
@@ -355,6 +356,7 @@ export component MailReadingPane inherits FocusScope {
|
||||
{ id: "copy", title: @tr("Copy to…"), icon: @image-url("../icons/lucide/outline/copy.svg") },
|
||||
{ id: "unsubscribe", title: @tr("Unsubscribe…"), icon: @image-url("../icons/lucide/outline/mail-x.svg"), disabled: !Unsubscribe.available },
|
||||
{ id: "spam", title: @tr("Move to spam"), icon: @image-url("../icons/lucide/outline/alert.svg") },
|
||||
{ id: "block", title: BlockSender.blocked ? @tr("Unblock sender") : @tr("Block sender…"), icon: @image-url("../icons/lucide/outline/ban.svg"), disabled: !BlockSender.available },
|
||||
{ id: root.trash_view ? "delete_permanently" : "trash", title: root.trash_view ? @tr("Delete permanently…") : @tr("Move to trash"), icon: @image-url("../icons/lucide/outline/trash.svg"), destructive: true },
|
||||
];
|
||||
private property <[ActionMenuEntry]> message-action-menu-no-print: [
|
||||
@@ -376,6 +378,7 @@ export component MailReadingPane inherits FocusScope {
|
||||
{ id: "copy", title: @tr("Copy to…"), icon: @image-url("../icons/lucide/outline/copy.svg") },
|
||||
{ id: "unsubscribe", title: @tr("Unsubscribe…"), icon: @image-url("../icons/lucide/outline/mail-x.svg"), disabled: !Unsubscribe.available },
|
||||
{ id: "spam", title: @tr("Move to spam"), icon: @image-url("../icons/lucide/outline/alert.svg") },
|
||||
{ id: "block", title: BlockSender.blocked ? @tr("Unblock sender") : @tr("Block sender…"), icon: @image-url("../icons/lucide/outline/ban.svg"), disabled: !BlockSender.available },
|
||||
{ id: root.trash_view ? "delete_permanently" : "trash", title: root.trash_view ? @tr("Delete permanently…") : @tr("Move to trash"), icon: @image-url("../icons/lucide/outline/trash.svg"), destructive: true },
|
||||
];
|
||||
// Local mail has no archive or spam.
|
||||
@@ -1078,6 +1081,8 @@ export component MailReadingPane inherits FocusScope {
|
||||
AccountMailPreferences.read-message();
|
||||
} else if action == "unsubscribe" {
|
||||
Unsubscribe.start();
|
||||
} else if action == "block" {
|
||||
BlockSender.start();
|
||||
} else if action == "print" {
|
||||
root.print_message();
|
||||
} else if action == "browser" {
|
||||
|
||||
Reference in new issue
Block a user