mirror of
https://github.com/flectar/mail.git
synced 2026-11-11 17:57:54 +00:00
feat: unsubscribe from mailing lists in the reader
This commit is contained in:
1 parent
0cc48d7ba9
commit
01d47d0d18
16 files changed
+1584
-675
No files matched your search
+779
-672
File diff suppressed because it is too large.
Load diff
+17
-1
@@ -324,6 +324,16 @@ pub(super) fn reopen_draft(draft_id: i64) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Open a new message already written, for the user to look over and send,
|
||||
/// such as an emailed unsubscribe request (RFC 2369 appendix B.2.4).
|
||||
pub(super) fn compose_written(message: SaveDraftArgs) {
|
||||
with_composers(|composers| {
|
||||
if let Some(app) = composers.app.upgrade() {
|
||||
composers.show_written(&app, message, "new");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The application is ending. A message someone was writing, in the main
|
||||
/// window or one of its own, goes to Drafts: nothing else would keep it.
|
||||
pub(super) fn save_open_messages() {
|
||||
@@ -748,6 +758,12 @@ impl Composers {
|
||||
|
||||
/// Open a saved draft in a composer.
|
||||
fn show_draft(self: &Rc<Self>, app: &AppWindow, draft: SaveDraftArgs) {
|
||||
self.show_written(app, draft, "draft");
|
||||
}
|
||||
|
||||
/// Open a written message in a composer: a saved draft (`mode` "draft"),
|
||||
/// or a new message the user has yet to look over ("new").
|
||||
fn show_written(self: &Rc<Self>, app: &AppWindow, draft: SaveDraftArgs, mode: &str) {
|
||||
// A draft already open in a composer comes forward instead of opening
|
||||
// twice, where each copy's saves would overwrite the other's.
|
||||
if let Some(open) = draft.draft_id.and_then(|id| self.holding_draft(id)) {
|
||||
@@ -843,7 +859,7 @@ impl Composers {
|
||||
in_reply_to_header: draft.in_reply_to_header,
|
||||
draft_id: draft.draft_id,
|
||||
};
|
||||
session.show("draft", Some(draft_sender_email));
|
||||
session.show(mode, Some(draft_sender_email));
|
||||
}
|
||||
|
||||
/// A send reached its end: a sent message closes its composer, a failed
|
||||
|
||||
+71
-2
@@ -11,8 +11,8 @@ use flectar_mail_core::{
|
||||
DraftAttachmentIn, FolderInfo, Label, MailGrouping, MailHistory, MailProfile, MailProtocol,
|
||||
MailboxBadgeCounts, MessageCursor, MessageDetail, MessageSearchCursor, MessageSummary,
|
||||
PerformActionArgs, PortableAccountConfig, Provider, QueueSendArgs, QueueSendResult,
|
||||
SaveDraftArgs, SearchCursor, Settings, Snippet, ThreadCursor, ThreadSummary, View,
|
||||
normalized_workspace_list_pane_width, roles,
|
||||
SaveDraftArgs, SearchCursor, Settings, Snippet, ThreadCursor, ThreadSummary,
|
||||
UnsubscribeOffer, UnsubscribeOutcome, View, normalized_workspace_list_pane_width, roles,
|
||||
},
|
||||
};
|
||||
#[cfg(test)]
|
||||
@@ -87,6 +87,9 @@ pub struct MailMessage {
|
||||
/// Empty, or the strongest receiver-reported authentication kind:
|
||||
/// domain, microsoft, bimi, or brand.
|
||||
pub sender_verification: String,
|
||||
/// 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,
|
||||
/// 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.
|
||||
@@ -129,6 +132,7 @@ impl MailMessage {
|
||||
text: None,
|
||||
body_pending: false,
|
||||
sender_verification: String::new(),
|
||||
can_unsubscribe: false,
|
||||
verification_code: None,
|
||||
account_uses_labels: false,
|
||||
first_attachment: None,
|
||||
@@ -1815,6 +1819,63 @@ impl CoreMailSource {
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
/// The message a row leaves its mailing list from: the message itself,
|
||||
/// or the newest of a conversation's that names a way to.
|
||||
pub async fn unsubscribe_message_of(&self, target: MailTarget) -> Result<Option<i64>, String> {
|
||||
match target {
|
||||
MailTarget::Message { id, .. } => Ok(Some(id)),
|
||||
MailTarget::Thread(thread_id) => self
|
||||
.core
|
||||
.thread_unsubscribe_message(thread_id)
|
||||
.await
|
||||
.map_err(|error| error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn unsubscribe_offer(
|
||||
&self,
|
||||
message_id: i64,
|
||||
) -> Result<Option<UnsubscribeOffer>, String> {
|
||||
self.core
|
||||
.unsubscribe_offer(message_id)
|
||||
.await
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
pub async fn unsubscribe(&self, message_id: i64) -> Result<UnsubscribeOutcome, String> {
|
||||
self.core
|
||||
.unsubscribe_message(message_id)
|
||||
.await
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
/// The inbox messages from the list `message_id` came from.
|
||||
pub async fn list_inbox_messages(&self, message_id: i64) -> Result<Vec<i64>, String> {
|
||||
self.core
|
||||
.list_inbox_messages(message_id)
|
||||
.await
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
/// Archive or delete these messages; the ids of the actions, for Undo.
|
||||
pub async fn act_on_messages(
|
||||
&self,
|
||||
message_ids: Vec<i64>,
|
||||
action: &str,
|
||||
) -> Result<Vec<i64>, String> {
|
||||
let kind = ActionKind::parse(action)
|
||||
.ok_or_else(|| format!("unsupported message action: {action}"))?;
|
||||
self.core
|
||||
.perform_action(PerformActionArgs {
|
||||
kind,
|
||||
target: ActionTarget::Messages(message_ids),
|
||||
params: None,
|
||||
})
|
||||
.await
|
||||
.map(|result| result.action_ids)
|
||||
.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
|
||||
@@ -2977,6 +3038,7 @@ fn summary_to_message(
|
||||
text: None,
|
||||
body_pending: true,
|
||||
sender_verification: String::new(),
|
||||
can_unsubscribe: false,
|
||||
verification_code: thread.verification_code,
|
||||
account_uses_labels: thread_account
|
||||
.is_some_and(|account| account.mail_protocol == MailProtocol::Gmail),
|
||||
@@ -3082,6 +3144,7 @@ fn message_summary_to_message(
|
||||
text: None,
|
||||
body_pending: true,
|
||||
sender_verification: String::new(),
|
||||
can_unsubscribe: false,
|
||||
verification_code: message.verification_code,
|
||||
account_uses_labels: message_account
|
||||
.is_some_and(|account| account.mail_protocol == MailProtocol::Gmail),
|
||||
@@ -3192,6 +3255,12 @@ fn detail_to_message(row: &MailMessage, message: &MessageDetail, body_loaded: bo
|
||||
},
|
||||
body_pending: message.body_state != "cached",
|
||||
sender_verification: message.sender_verification.as_str().to_owned(),
|
||||
can_unsubscribe: !message.is_draft
|
||||
&& !message.is_outgoing
|
||||
&& !message.in_spam
|
||||
&& message.list_unsubscribe.as_deref().is_some_and(|header| {
|
||||
flectar_mail_core::unsubscribe::plan(header, false).is_some()
|
||||
}),
|
||||
verification_code: row.verification_code.clone(),
|
||||
account_uses_labels: row.account_uses_labels,
|
||||
first_attachment: row.first_attachment.clone(),
|
||||
|
||||
@@ -119,6 +119,7 @@ pub(super) fn email_viewport_size(app: &AppWindow) -> (u32, u32) {
|
||||
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 reader = app.global::<EmailReader>();
|
||||
reader.set_available(false);
|
||||
reader.set_body_pending(false);
|
||||
@@ -157,6 +158,8 @@ pub(super) fn apply_email(
|
||||
app.set_selected_starred(email.starred);
|
||||
app.set_selected_unread(email.unread);
|
||||
app.set_selected_sender_verification(email.sender_verification.clone().into());
|
||||
app.global::<Unsubscribe>()
|
||||
.set_available(email.can_unsubscribe);
|
||||
let reader = app.global::<EmailReader>();
|
||||
reader.set_body_pending(email.body_pending);
|
||||
reader.set_authored_text(email.text.clone().unwrap_or_default().into());
|
||||
|
||||
@@ -1871,6 +1871,7 @@ mod tests {
|
||||
attachments: Vec::new(),
|
||||
body_pending: true,
|
||||
sender_verification: String::new(),
|
||||
can_unsubscribe: false,
|
||||
verification_code: None,
|
||||
account_uses_labels: false,
|
||||
first_attachment: None,
|
||||
|
||||
@@ -89,6 +89,7 @@ mod theme;
|
||||
mod ui_dispatch;
|
||||
mod ui_message;
|
||||
mod undo;
|
||||
mod unsubscribe;
|
||||
mod updates;
|
||||
mod window_chrome;
|
||||
mod window_controller;
|
||||
@@ -6081,6 +6082,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());
|
||||
#[cfg(target_os = "macos")]
|
||||
mailto_links::receive_from_macos(&app);
|
||||
notifications::ui::attach(&app, &state, &runtime, platform.notifications.clone());
|
||||
|
||||
@@ -140,6 +140,8 @@ impl From<WarmStartMessage> for mail::MailMessage {
|
||||
attachments: Vec::new(),
|
||||
body_pending: true,
|
||||
sender_verification: message.sender_verification,
|
||||
// The banner waits for the message itself.
|
||||
can_unsubscribe: false,
|
||||
verification_code: None,
|
||||
account_uses_labels: message.account_uses_labels,
|
||||
first_attachment: None,
|
||||
@@ -1267,6 +1269,7 @@ mod warm_start_tests {
|
||||
attachments: Vec::new(),
|
||||
body_pending: false,
|
||||
sender_verification: "domain".into(),
|
||||
can_unsubscribe: false,
|
||||
verification_code: Some("48291375".into()),
|
||||
account_uses_labels: true,
|
||||
first_attachment: Some(flectar_mail_core::models::AttachmentMeta {
|
||||
|
||||
@@ -34,6 +34,8 @@ 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> },
|
||||
/// News with nothing to take back.
|
||||
Note,
|
||||
}
|
||||
|
||||
struct Undo {
|
||||
@@ -134,6 +136,12 @@ pub(super) fn gesture(app: &AppWindow, action_ids: Vec<i64>, batch: UndoableBatc
|
||||
});
|
||||
}
|
||||
|
||||
/// Tell of something done that cannot be taken back here, such as leaving
|
||||
/// a mailing list.
|
||||
pub(super) fn tell(app: &AppWindow, message: UiMessage) {
|
||||
with_undo(|undo| undo.show(app, Shown::Note, message, "", Some(ANSWER_NOTICE_FOR)));
|
||||
}
|
||||
|
||||
/// What the notice says of a batch.
|
||||
fn gesture_message(batch: UndoableBatch) -> UiMessage {
|
||||
let count = batch.count;
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
//! Leaving a mailing list from the reader (#227). The core works out how the
|
||||
//! list takes a request; a dialog asks first, as RFC 2369 and RFC 8058 want;
|
||||
//! and after the request, the list's other mail in the inbox can be
|
||||
//! archived or moved to Trash at once, with Undo.
|
||||
|
||||
use super::*;
|
||||
use crate::mail_work::UndoableBatch;
|
||||
use flectar_mail_core::models::{Address, SaveDraftArgs, UnsubscribeOffer, UnsubscribeOutcome};
|
||||
use std::future::Future;
|
||||
|
||||
/// The list the dialog is about.
|
||||
struct Leaving {
|
||||
message_id: i64,
|
||||
account_id: i64,
|
||||
offer: UnsubscribeOffer,
|
||||
/// Its messages in the inbox, once the request is made.
|
||||
inbox: Vec<i64>,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static LEAVING: RefCell<Option<Leaving>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
pub(super) fn install(
|
||||
app: &AppWindow,
|
||||
state: &Rc<RefCell<InboxState>>,
|
||||
runtime: &tokio::runtime::Handle,
|
||||
opener: ExternalUrlOpener,
|
||||
) {
|
||||
let ui = app.global::<Unsubscribe>();
|
||||
let controller = Rc::new(Controller {
|
||||
app: app.as_weak(),
|
||||
state: Rc::clone(state),
|
||||
runtime: runtime.clone(),
|
||||
opener,
|
||||
});
|
||||
let this = Rc::clone(&controller);
|
||||
ui.on_start(move || this.start());
|
||||
let this = Rc::clone(&controller);
|
||||
ui.on_confirm(move || this.confirm());
|
||||
let this = Rc::clone(&controller);
|
||||
ui.on_review(move || this.review());
|
||||
let this = Rc::clone(&controller);
|
||||
ui.on_clean_up(move |action| this.clean_up(&action));
|
||||
let this = controller;
|
||||
ui.on_close(move || this.close());
|
||||
}
|
||||
|
||||
struct Controller {
|
||||
app: slint::Weak<AppWindow>,
|
||||
state: Rc<RefCell<InboxState>>,
|
||||
runtime: tokio::runtime::Handle,
|
||||
opener: ExternalUrlOpener,
|
||||
}
|
||||
|
||||
impl Controller {
|
||||
/// The banner or the menu: find out how the open message's list takes a
|
||||
/// request, and ask.
|
||||
fn start(&self) {
|
||||
let Some(app) = self.app.upgrade() else {
|
||||
return;
|
||||
};
|
||||
if !app.global::<Unsubscribe>().get_stage().is_empty() {
|
||||
return;
|
||||
}
|
||||
let (core, row) = {
|
||||
let state = self.state.borrow();
|
||||
let row = state
|
||||
.selected_id
|
||||
.and_then(|id| state.messages.iter().find(|message| message.id == id))
|
||||
.cloned();
|
||||
(state.core.clone(), row)
|
||||
};
|
||||
let (Some(core), Some(row)) = (core, row) else {
|
||||
return;
|
||||
};
|
||||
let Some(target) = MailTarget::of(&row) else {
|
||||
return;
|
||||
};
|
||||
let account_id = row.account_id;
|
||||
let sender = app.get_selected_sender().to_string();
|
||||
self.spawn(
|
||||
async move {
|
||||
let Some(message_id) = core.unsubscribe_message_of(target).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(core
|
||||
.unsubscribe_offer(message_id)
|
||||
.await?
|
||||
.map(|offer| (message_id, offer)))
|
||||
},
|
||||
move |app, result| match result {
|
||||
Ok(Some((message_id, offer))) => {
|
||||
let ui = app.global::<Unsubscribe>();
|
||||
ui.set_sender(sender.into());
|
||||
show_offer(&ui, &offer);
|
||||
ui.set_inbox_count(0);
|
||||
ui.set_stage("confirm".into());
|
||||
LEAVING.with(|slot| {
|
||||
*slot.borrow_mut() = Some(Leaving {
|
||||
message_id,
|
||||
account_id,
|
||||
offer,
|
||||
inbox: Vec::new(),
|
||||
});
|
||||
});
|
||||
}
|
||||
Ok(None) => crate::undo::tell(
|
||||
&app,
|
||||
UiMessage::plain("This message names no way to unsubscribe."),
|
||||
),
|
||||
Err(error) => {
|
||||
crate::undo::tell(&app, UiMessage::detail("Could not unsubscribe: {}", error))
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// The dialog's main button: make the request, or open the list's page.
|
||||
fn confirm(&self) {
|
||||
let Some(app) = self.app.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let Some((message_id, offer)) = LEAVING.with(|slot| {
|
||||
slot.borrow()
|
||||
.as_ref()
|
||||
.map(|leaving| (leaving.message_id, leaving.offer.clone()))
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
if let UnsubscribeOffer::Website { url } = &offer {
|
||||
let url = url.clone();
|
||||
self.close();
|
||||
if let Err(error) = crate::mail_render_projection::open_email_link(&self.opener, &url) {
|
||||
crate::undo::tell(&app, UiMessage::detail("Could not open link: {}", error));
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some(core) = self.state.borrow().core.clone() else {
|
||||
return;
|
||||
};
|
||||
let ui = app.global::<Unsubscribe>();
|
||||
ui.set_stage("working".into());
|
||||
self.spawn(
|
||||
async move {
|
||||
let outcome = core.unsubscribe(message_id).await?;
|
||||
let inbox = match outcome {
|
||||
UnsubscribeOutcome::NeedsBrowser { .. } => Vec::new(),
|
||||
// The request is made: a failure to count the list's
|
||||
// mail only means not offering to clear it.
|
||||
_ => core
|
||||
.list_inbox_messages(message_id)
|
||||
.await
|
||||
.unwrap_or_default(),
|
||||
};
|
||||
Ok((outcome, inbox))
|
||||
},
|
||||
move |app, result| {
|
||||
// Closed while the request went out: the request still
|
||||
// counts, and the notice tells of it.
|
||||
let open = LEAVING.with(|slot| {
|
||||
slot.borrow()
|
||||
.as_ref()
|
||||
.is_some_and(|leaving| leaving.message_id == message_id)
|
||||
});
|
||||
let ui = app.global::<Unsubscribe>();
|
||||
match result {
|
||||
Ok((UnsubscribeOutcome::NeedsBrowser { url }, _)) if open => {
|
||||
ui.set_host(host_of(&url).into());
|
||||
ui.set_stage("website".into());
|
||||
LEAVING.with(|slot| {
|
||||
if let Some(leaving) = slot.borrow_mut().as_mut() {
|
||||
leaving.offer = UnsubscribeOffer::Website { url };
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok((UnsubscribeOutcome::NeedsBrowser { .. }, _)) => {}
|
||||
Ok((outcome, inbox)) => {
|
||||
let sender = ui.get_sender().to_string();
|
||||
let to = ui.get_to().to_string();
|
||||
if open && !inbox.is_empty() {
|
||||
ui.set_inbox_count(i32::try_from(inbox.len()).unwrap_or(i32::MAX));
|
||||
ui.set_stage("done".into());
|
||||
LEAVING.with(|slot| {
|
||||
if let Some(leaving) = slot.borrow_mut().as_mut() {
|
||||
leaving.inbox = inbox;
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
close_dialog(&app);
|
||||
crate::undo::tell(
|
||||
&app,
|
||||
match outcome {
|
||||
UnsubscribeOutcome::MailtoSent => {
|
||||
UiMessage::detail("Unsubscribe request sent to {}.", to)
|
||||
}
|
||||
_ => UiMessage::detail("Unsubscribed from {}.", sender),
|
||||
},
|
||||
);
|
||||
}
|
||||
Err(error) if open => {
|
||||
ui.set_error(error.into());
|
||||
ui.set_stage("error".into());
|
||||
}
|
||||
Err(error) => crate::undo::tell(
|
||||
&app,
|
||||
UiMessage::detail("Could not unsubscribe: {}", error),
|
||||
),
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Open an emailed request in the composer, to be looked over and sent
|
||||
/// there (RFC 2369 appendix B.2.4).
|
||||
fn review(&self) {
|
||||
let Some(leaving) = LEAVING.with(|slot| slot.borrow_mut().take()) else {
|
||||
return;
|
||||
};
|
||||
self.close();
|
||||
let UnsubscribeOffer::Mailto {
|
||||
from,
|
||||
to,
|
||||
subject,
|
||||
body,
|
||||
} = leaving.offer
|
||||
else {
|
||||
return;
|
||||
};
|
||||
crate::composer::compose_written(SaveDraftArgs {
|
||||
draft_id: None,
|
||||
account_id: leaving.account_id,
|
||||
from: Some(Address {
|
||||
name: None,
|
||||
email: from,
|
||||
}),
|
||||
to: vec![Address {
|
||||
name: None,
|
||||
email: to,
|
||||
}],
|
||||
cc: Vec::new(),
|
||||
bcc: Vec::new(),
|
||||
subject,
|
||||
body_text: body,
|
||||
body_html: None,
|
||||
mode: "new".into(),
|
||||
in_reply_to_message_id: None,
|
||||
in_reply_to_header: None,
|
||||
attachments: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Archive the list's messages in the inbox, or move them to Trash.
|
||||
fn clean_up(&self, action: &str) {
|
||||
let Some(inbox) = LEAVING.with(|slot| slot.borrow().as_ref().map(|l| l.inbox.clone()))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
self.close();
|
||||
let Some(core) = self.state.borrow().core.clone() else {
|
||||
return;
|
||||
};
|
||||
let kind: &'static str = if action == "trash" {
|
||||
"trash"
|
||||
} else {
|
||||
"archive"
|
||||
};
|
||||
let count = inbox.len();
|
||||
self.spawn(
|
||||
async move { core.act_on_messages(inbox, kind).await },
|
||||
move |app, result| match result {
|
||||
Ok(action_ids) if !action_ids.is_empty() => crate::undo::gesture(
|
||||
&app,
|
||||
action_ids,
|
||||
UndoableBatch {
|
||||
kind,
|
||||
conversations: false,
|
||||
count,
|
||||
},
|
||||
),
|
||||
Ok(_) => {}
|
||||
Err(error) => {
|
||||
app.set_render_status(UiMessage::detail("Message action failed: {}", error))
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn close(&self) {
|
||||
LEAVING.with(|slot| slot.borrow_mut().take());
|
||||
if let Some(app) = self.app.upgrade() {
|
||||
close_dialog(&app);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn close_dialog(app: &AppWindow) {
|
||||
app.global::<Unsubscribe>().set_stage("".into());
|
||||
}
|
||||
|
||||
/// What the dialog says about how the list takes the request.
|
||||
fn show_offer(ui: &Unsubscribe, offer: &UnsubscribeOffer) {
|
||||
match offer {
|
||||
UnsubscribeOffer::OneClick { host } => {
|
||||
ui.set_kind("one-click".into());
|
||||
ui.set_host(host.into());
|
||||
}
|
||||
UnsubscribeOffer::Mailto { from, to, .. } => {
|
||||
ui.set_kind("mailto".into());
|
||||
ui.set_from(from.into());
|
||||
ui.set_to(to.into());
|
||||
}
|
||||
UnsubscribeOffer::Website { url } => {
|
||||
ui.set_kind("website".into());
|
||||
ui.set_host(host_of(url).into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The host a web address names, or the address itself.
|
||||
fn host_of(url: &str) -> String {
|
||||
url::Url::parse(url)
|
||||
.ok()
|
||||
.and_then(|url| url.host_str().map(str::to_owned))
|
||||
.unwrap_or_else(|| url.to_owned())
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export { DefaultEmailApp } from "default-email-app.slint";
|
||||
export { SystemNotifications } from "system-notifications.slint";
|
||||
export { Updates } from "updates.slint";
|
||||
export { Notice } from "notice.slint";
|
||||
export { Unsubscribe } from "unsubscribe.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.
|
||||
@@ -39,6 +40,8 @@ import { CalendarEventDialog } from "dialogs/calendar-event-dialog.slint";
|
||||
import { ComposeDialog } from "dialogs/compose-dialog.slint";
|
||||
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 { BoundComposer, ComposerUi } from "components/composer-ui.slint";
|
||||
import { ComposerText } from "components/composer-fields.slint";
|
||||
export { ComposerUi } from "components/composer-ui.slint";
|
||||
@@ -585,6 +588,10 @@ export global I18n {
|
||||
if (key == "{} messages unstarred.") { return @tr("Message unstarred." | "{0} messages unstarred." % round(argument.to-float()), argument); }
|
||||
if (key == "This can no longer be undone.") { return @tr("This can no longer be undone."); }
|
||||
if (key == "Could not undo: {}") { return @tr("Could not undo: {}", argument); }
|
||||
if (key == "This message names no way to unsubscribe.") { return @tr("This message names no way to unsubscribe."); }
|
||||
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 == "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); }
|
||||
@@ -2308,6 +2315,14 @@ export component AppWindow inherits Window {
|
||||
}
|
||||
}
|
||||
|
||||
if Unsubscribe.stage != "" : UnsubscribeDialog {
|
||||
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,231 @@
|
||||
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 { Unsubscribe } from "../unsubscribe.slint";
|
||||
|
||||
// Asks before leaving a mailing list, as RFC 2369 and RFC 8058 want, says
|
||||
// how it went, and then offers to clear the list's mail out of the inbox
|
||||
// (src/unsubscribe.rs).
|
||||
export component UnsubscribeDialog inherits Rectangle {
|
||||
private property <bool> narrow: panel.width < 360px;
|
||||
private property <bool> emailed: Unsubscribe.kind == "mailto";
|
||||
// Asking first, and while the request goes out: the dialog keeps what
|
||||
// it asked, its buttons disabled.
|
||||
private property <bool> asking: Unsubscribe.stage == "confirm" || Unsubscribe.stage == "working";
|
||||
private property <bool> working: Unsubscribe.stage == "working";
|
||||
private property <string> title: Unsubscribe.stage == "error" ? @tr("Could not unsubscribe")
|
||||
: Unsubscribe.stage == "done" && root.emailed ? @tr("Unsubscribe request sent")
|
||||
: Unsubscribe.stage == "done" ? @tr("Unsubscribed from {}", Unsubscribe.sender)
|
||||
: @tr("Unsubscribe from {}?", Unsubscribe.sender);
|
||||
private property <string> description: Unsubscribe.stage == "error" ? Unsubscribe.error
|
||||
: Unsubscribe.stage == "website" && Unsubscribe.kind == "one-click"
|
||||
? @tr("{} did not take the request. You can unsubscribe on its website instead.", Unsubscribe.host)
|
||||
: Unsubscribe.stage == "done" && root.emailed
|
||||
? @tr("Your request went to {}. It can take a few days for the mail to stop.", Unsubscribe.to)
|
||||
: Unsubscribe.stage == "done" ? @tr("It can take a few days for the mail to stop.")
|
||||
: Unsubscribe.kind == "one-click" ? @tr("Flectar Mail will ask {} to stop sending you this mail.", Unsubscribe.host)
|
||||
: root.emailed ? @tr("Flectar Mail will send an email asking the list to unsubscribe you.")
|
||||
: @tr("{} takes unsubscribe requests on its website.", Unsubscribe.host);
|
||||
private property <string> detail: root.asking && Unsubscribe.kind == "website" ? @tr("The page opens in your browser.")
|
||||
: Unsubscribe.stage == "confirm" && root.emailed ? @tr("You can review the email before it goes.")
|
||||
: root.asking ? @tr("It can take a few days for the mail to stop.")
|
||||
: "";
|
||||
private property <bool> cleaning: Unsubscribe.stage == "done" && Unsubscribe.inbox-count > 0;
|
||||
|
||||
visible: Unsubscribe.stage != "";
|
||||
background: #00000088;
|
||||
z: 1000;
|
||||
accessible-role: groupbox;
|
||||
accessible-label: root.title;
|
||||
|
||||
function cancel() {
|
||||
if !root.working {
|
||||
Unsubscribe.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(460px, parent.width - 32px);
|
||||
height: max(212px, 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/mail-x.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: root.description;
|
||||
color: AppTheme.body;
|
||||
font-size: TypeScale.secondary;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
// RFC 2369 appendix B.1: where an emailed request goes, and
|
||||
// from which address. Each on a line of its own, so an
|
||||
// address doesn't break in two.
|
||||
if root.asking && root.emailed : GridLayout {
|
||||
spacing-horizontal: 12px;
|
||||
spacing-vertical: 4px;
|
||||
Row {
|
||||
Text { text: @tr("To"); color: AppTheme.muted; font-size: TypeScale.secondary; }
|
||||
Text { text: Unsubscribe.to; color: AppTheme.body; font-size: TypeScale.secondary; font-weight: 600; wrap: char-wrap; horizontal-stretch: 1; }
|
||||
}
|
||||
Row {
|
||||
Text { text: @tr("From"); color: AppTheme.muted; font-size: TypeScale.secondary; }
|
||||
Text { text: Unsubscribe.from; color: AppTheme.body; font-size: TypeScale.secondary; font-weight: 600; wrap: char-wrap; horizontal-stretch: 1; }
|
||||
}
|
||||
}
|
||||
if root.cleaning : Text {
|
||||
height: self.preferred-height;
|
||||
text: @tr("One message from this list is still in your inbox." | "{n} messages from this list are still in your inbox." % Unsubscribe.inbox-count);
|
||||
color: AppTheme.body;
|
||||
font-size: TypeScale.secondary;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
if root.detail != "" : Text {
|
||||
height: self.preferred-height;
|
||||
text: root.detail;
|
||||
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.
|
||||
if root.asking || Unsubscribe.stage == "website" : GridLayout {
|
||||
spacing: 8px;
|
||||
Button {
|
||||
row: root.narrow ? (root.emailed && root.asking ? 2 : 1) : 0;
|
||||
col: 0;
|
||||
height: 34px;
|
||||
enabled: !root.working;
|
||||
text: @tr("Cancel");
|
||||
clicked => { Unsubscribe.close(); }
|
||||
}
|
||||
if root.emailed && root.asking : Button {
|
||||
row: root.narrow ? 1 : 0;
|
||||
col: root.narrow ? 0 : 1;
|
||||
height: 34px;
|
||||
enabled: !root.working;
|
||||
text: @tr("Review email");
|
||||
clicked => { Unsubscribe.review(); }
|
||||
}
|
||||
Button {
|
||||
row: 0;
|
||||
col: root.narrow ? 0 : (root.emailed && root.asking ? 2 : 1);
|
||||
height: 34px;
|
||||
primary: true;
|
||||
enabled: !root.working;
|
||||
text: root.working ? @tr("Unsubscribing…")
|
||||
: Unsubscribe.kind == "website" || Unsubscribe.stage == "website" ? @tr("Open website")
|
||||
: @tr("Unsubscribe");
|
||||
clicked => { Unsubscribe.confirm(); }
|
||||
}
|
||||
}
|
||||
if root.cleaning : GridLayout {
|
||||
spacing: 8px;
|
||||
Button {
|
||||
row: root.narrow ? 2 : 0;
|
||||
col: 0;
|
||||
height: 34px;
|
||||
text: @tr("Keep them");
|
||||
clicked => { Unsubscribe.close(); }
|
||||
}
|
||||
Button {
|
||||
row: root.narrow ? 1 : 0;
|
||||
col: root.narrow ? 0 : 1;
|
||||
height: 34px;
|
||||
text: @tr("Move to trash");
|
||||
clicked => { Unsubscribe.clean-up("trash"); }
|
||||
}
|
||||
Button {
|
||||
row: 0;
|
||||
col: root.narrow ? 0 : 2;
|
||||
height: 34px;
|
||||
primary: true;
|
||||
text: @tr("Archive");
|
||||
clicked => { Unsubscribe.clean-up("archive"); }
|
||||
}
|
||||
}
|
||||
if Unsubscribe.stage == "error" : HorizontalBox {
|
||||
padding: 0px;
|
||||
spacing: 8px;
|
||||
Rectangle { horizontal-stretch: 1; }
|
||||
if root.emailed : Button {
|
||||
height: 34px;
|
||||
text: @tr("Review email");
|
||||
clicked => { Unsubscribe.review(); }
|
||||
}
|
||||
Button {
|
||||
height: 34px;
|
||||
min-width: 96px;
|
||||
primary: true;
|
||||
text: @tr("Close");
|
||||
clicked => { Unsubscribe.close(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<!-- Generated display outline; originals and license in ../sources/ and ../LICENSE. -->
|
||||
<!-- @license lucide-static v1.46.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-mail-x"
|
||||
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"
|
||||
>
|
||||
<path d="M22 12.532V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8.792" />
|
||||
<path d="m22 7-8.991 5.727a2 2 0 0 1-2.009 0L2 7" />
|
||||
<path d="m16.5 16.5 5 5" />
|
||||
<path d="m21.5 16.5-5 5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 572 B |
@@ -135,6 +135,9 @@
|
||||
"mail.svg": {
|
||||
"source": "mail"
|
||||
},
|
||||
"mail-x.svg": {
|
||||
"source": "mail-x"
|
||||
},
|
||||
"paper-airplane.svg": {
|
||||
"source": "send"
|
||||
},
|
||||
@@ -459,6 +462,10 @@
|
||||
"path": "sources/mail-open.svg",
|
||||
"sha256": "956ac99f10de1ea60b444ed7f4e7834a6604a2e57e4c71b349106def64ea5d9b"
|
||||
},
|
||||
"mail-x": {
|
||||
"path": "sources/mail-x.svg",
|
||||
"sha256": "60f0553127a77adbefafd92cdfc692988444e8a4df0abba4494295cbc4f1fe38"
|
||||
},
|
||||
"maximize": {
|
||||
"path": "sources/maximize.svg",
|
||||
"sha256": "ec608f4e02fa0f9ec9da6b0b9b50b4796def96462d2095211ea2714b740db35b"
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<!-- @license lucide-static v1.46.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-mail-x"
|
||||
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"
|
||||
>
|
||||
<path d="M22 12.532V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8.792" />
|
||||
<path d="m22 7-8.991 5.727a2 2 0 0 1-2.009 0L2 7" />
|
||||
<path d="m16.5 16.5 5 5" />
|
||||
<path d="m21.5 16.5-5 5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 481 B |
@@ -0,0 +1,28 @@
|
||||
// Leaving the mailing list the open message came from (src/unsubscribe.rs).
|
||||
export global Unsubscribe {
|
||||
// The open message names a way to leave its list.
|
||||
in property <bool> available;
|
||||
// Where the dialog is: "" (closed), "confirm", "working", "done",
|
||||
// "website" (the list's page has to finish it) or "error".
|
||||
in property <string> stage;
|
||||
// How the list takes the request: "one-click", "mailto" or "website".
|
||||
in property <string> kind;
|
||||
// Who the list mail is from.
|
||||
in property <string> sender;
|
||||
// The web host a request goes to.
|
||||
in property <string> host;
|
||||
// An emailed request's address, and the address it goes from.
|
||||
in property <string> to;
|
||||
in property <string> from;
|
||||
// After the request, the list's messages still in the inbox.
|
||||
in property <int> inbox-count;
|
||||
// Why the request failed, as the core put it.
|
||||
in property <string> error;
|
||||
callback start();
|
||||
callback confirm();
|
||||
// Open an emailed request in the composer instead of sending it.
|
||||
callback review();
|
||||
// "archive" or "trash" the list's messages in the inbox.
|
||||
callback clean-up(string);
|
||||
callback close();
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import { MessageColors } from "../message-colors.slint";
|
||||
import { WindowChrome, WindowControlsClearance } from "../window-chrome.slint";
|
||||
import { Shortcuts } from "../shortcuts.slint";
|
||||
import { LocalMailUi } from "../local-mail.slint";
|
||||
import { Unsubscribe } from "../unsubscribe.slint";
|
||||
|
||||
component ReaderControl inherits ToolbarIconButton {
|
||||
tooltip-above: true;
|
||||
@@ -352,6 +353,7 @@ export component MailReadingPane inherits FocusScope {
|
||||
{ id: "archive", title: @tr("Archive"), icon: @image-url("../icons/lucide/outline/archive.svg") },
|
||||
{ id: "move", title: @tr("Move to…"), icon: @image-url("../icons/lucide/outline/folder-move.svg") },
|
||||
{ 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: 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 },
|
||||
];
|
||||
@@ -372,6 +374,7 @@ export component MailReadingPane inherits FocusScope {
|
||||
{ id: "archive", title: @tr("Archive"), icon: @image-url("../icons/lucide/outline/archive.svg") },
|
||||
{ id: "move", title: @tr("Move to…"), icon: @image-url("../icons/lucide/outline/folder-move.svg") },
|
||||
{ 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: 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 },
|
||||
];
|
||||
@@ -1073,6 +1076,8 @@ export component MailReadingPane inherits FocusScope {
|
||||
label-popup.show();
|
||||
} else if action == "openpgp" {
|
||||
AccountMailPreferences.read-message();
|
||||
} else if action == "unsubscribe" {
|
||||
Unsubscribe.start();
|
||||
} else if action == "print" {
|
||||
root.print_message();
|
||||
} else if action == "browser" {
|
||||
@@ -1311,6 +1316,35 @@ export component MailReadingPane inherits FocusScope {
|
||||
ReaderControl { compact: root.compact; icon: @image-url("../icons/lucide/outline/chevron-down.svg"); tooltip-text: @tr("Next match"); clicked => { EmailReader.find(EmailReader.query, 1); } }
|
||||
ReaderControl { compact: root.compact; icon: @image-url("../icons/lucide/outline/x.svg"); tooltip-text: @tr("Close find"); clicked => { EmailReader.find-open = false; } }
|
||||
}
|
||||
// RFC 2369 list mail: a way out, above the message as
|
||||
// Apple Mail, Outlook and Fastmail put it.
|
||||
if Unsubscribe.available && !LocalMailUi.selected : HorizontalBox {
|
||||
// The padding leaves the button's height, so the
|
||||
// text and icon center on it.
|
||||
height: root.compact ? 44px : 32px; padding: 0px; spacing: 8px;
|
||||
padding-top: root.compact ? 4px : 3px; padding-bottom: root.compact ? 4px : 3px;
|
||||
accessible-role: groupbox;
|
||||
accessible-label: @tr("This message is from a mailing list.");
|
||||
Rectangle {
|
||||
width: LayoutMetrics.icon-size;
|
||||
Image {
|
||||
y: (parent.height - self.height) / 2;
|
||||
width: LayoutMetrics.icon-size; height: LayoutMetrics.icon-size;
|
||||
source: @image-url("../icons/lucide/outline/mail-x.svg");
|
||||
colorize: root.secondary-text;
|
||||
image-fit: contain;
|
||||
accessible-role: none;
|
||||
}
|
||||
}
|
||||
Text { text: @tr("This message is from a mailing list."); overflow: elide; color: root.secondary-text; vertical-alignment: center; horizontal-stretch: 1; }
|
||||
Button {
|
||||
width: max(96px, self.content-width);
|
||||
height: root.compact ? 36px : 26px;
|
||||
text: @tr("Unsubscribe");
|
||||
accessible-id: "message-unsubscribe";
|
||||
clicked => { Unsubscribe.start(); }
|
||||
}
|
||||
}
|
||||
if EmailReader.failed-images > 0 && !root.remote-images-blocked && !root.text_mode && !root.source_mode && !EmailReader.reader-mode : HorizontalBox {
|
||||
height: root.compact ? 44px : 32px; padding: 0px; spacing: 6px;
|
||||
Text { text: @tr("Some images could not be loaded."); overflow: elide; color: root.secondary-text; vertical-alignment: center; horizontal-stretch: 1; }
|
||||
|
||||
Reference in new issue
Block a user