feat: block senders from the reader

This commit is contained in:
Tomás Romero committed 2026-11-11 02:40:26 +01:00
1 parent 1912158279
commit 14bb459f94
16 files changed
+1845 -1160

No files matched your search

+1222 -1153
View File
File diff suppressed because it is too large. Load diff
+222
View File
@@ -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
View File
@@ -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(),
+6
View File
@@ -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());
+2
View File
@@ -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,
+3
View File
@@ -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());
+4
View File
@@ -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
View File
@@ -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 = {
+15
View File
@@ -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;
+24
View File
@@ -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);
}
+136
View File
@@ -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(); }
}
}
}
}
}
}
+48
View File
@@ -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;
+17
View File
@@ -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

+7
View File
@@ -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"
+16
View File
@@ -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

+5
View File
@@ -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" {