fix: refine message reader

This commit is contained in:
Tomás Romero committed 2026-09-21 02:14:06 +00:00
1 parent 37fb3836a2
commit 2cc256078e
9 files changed
+415 -112

No files matched your search

+160 -6
View File
@@ -1,12 +1,13 @@
//! Mail attachment metadata and an independent dialog session. Files and Mail
//! share bounded decoders/PDF workers; opening Mail never changes Files state.
use crate::document_preview::{Preview, preview};
use crate::document_preview::{Preview, image_thumbnail, preview};
use crate::{
AppWindow, EmailReader, InboxState, MailAttachment, MailAttachments, mail::MailMessage,
};
use slint::{ComponentHandle, ModelRc, Rgba8Pixel, SharedPixelBuffer, VecModel};
use slint::{ComponentHandle, Model, ModelRc, Rgba8Pixel, SharedPixelBuffer, VecModel};
use std::{
cell::RefCell,
collections::HashSet,
rc::Rc,
sync::{
Arc,
@@ -29,12 +30,60 @@ pub(crate) fn clear(app: &AppWindow) {
ui.set_rows(ModelRc::default());
}
pub(crate) fn project(app: &AppWindow, email: &MailMessage, same: bool) {
let previous = same.then(|| {
let rows = app.global::<MailAttachments>().get_rows();
(0..rows.row_count())
.filter_map(|index| rows.row_data(index))
.filter(|row| row.has_thumbnail)
.map(|row| (row.id.to_string(), row.thumbnail))
.collect::<std::collections::HashMap<_, _>>()
});
if !same {
clear(app);
}
let rows = attachment_rows(&email.attachments);
let mut rows = attachment_rows(&email.attachments);
if let Some(previous) = previous {
for row in &mut rows {
if let Some(thumbnail) = previous.get(row.id.as_str()) {
row.thumbnail = thumbnail.clone();
row.has_thumbnail = true;
}
}
}
let thumbnail_ids = email
.attachments
.iter()
.filter(|attachment| {
thumbnail_candidate(attachment)
&& rows
.iter()
.find(|row| row.id.as_str() == attachment.id.to_string())
.is_some_and(|row| !row.has_thumbnail)
})
.take(4)
.map(|attachment| attachment.id.to_string())
.collect::<Vec<_>>();
app.global::<MailAttachments>()
.set_rows(ModelRc::new(VecModel::from(rows)));
for id in thumbnail_ids {
app.global::<MailAttachments>()
.invoke_command("thumbnail".into(), id.into());
}
}
fn thumbnail_candidate(attachment: &flectar_mail_core::models::AttachmentMeta) -> bool {
let media = attachment
.mime_type
.as_deref()
.unwrap_or("")
.split(';')
.next()
.unwrap_or("")
.trim()
.to_ascii_lowercase();
attachment.size.is_none_or(|size| size <= 4 * 1024 * 1024)
&& media.starts_with("image/")
&& media != "image/svg+xml"
}
fn attachment_rows(
attachments: &[flectar_mail_core::models::AttachmentMeta],
@@ -71,6 +120,8 @@ fn attachment_rows(
|| media.starts_with("text/")
|| matches!(media.as_str(), "application/json" | "application/xml")
|| name.to_ascii_lowercase().ends_with(".pdf"),
thumbnail: Default::default(),
has_thumbnail: false,
}
})
.collect::<Vec<_>>()
@@ -169,9 +220,56 @@ pub(crate) fn register(
Arc<flectar_mail_core::Core>,
Result<Option<Preview>, String>,
)>(2);
let (thumbnail_sender, mut thumbnail_receiver) = tokio::sync::mpsc::channel::<(
i32,
i64,
Arc<flectar_mail_core::Core>,
Result<(Vec<u8>, u32, u32), String>,
)>(8);
let (thumbnail_job_sender, mut thumbnail_job_receiver) =
tokio::sync::mpsc::channel::<(
i32,
flectar_mail_core::models::AttachmentMeta,
Arc<flectar_mail_core::Core>,
)>(8);
let thumbnail_window = weak.clone();
runtime.handle().spawn(async move {
while let Some((message, attachment, core)) = thumbnail_job_receiver.recv().await {
let attachment_id = attachment.id;
let result = async {
let path = core
.get_attachment(attachment_id)
.await
.map_err(|error| error.to_string())?;
use tokio::io::AsyncReadExt;
let file = tokio::fs::File::open(path)
.await
.map_err(|error| error.to_string())?;
let mut bytes = Vec::new();
file.take(4 * 1024 * 1024 + 1)
.read_to_end(&mut bytes)
.await
.map_err(|error| error.to_string())?;
image_thumbnail(bytes).await
}
.await;
if thumbnail_sender
.send((message, attachment_id, core, result))
.await
.is_err()
{
break;
}
let _ = thumbnail_window.upgrade_in_event_loop(|app| {
app.global::<MailAttachments>().invoke_deliver()
});
}
});
let thumbnail_pending = Rc::new(RefCell::new(HashSet::<(usize, i32, i64)>::new()));
let state = inbox.clone();
let session_for_delivery = session.clone();
let current = generation.clone();
let pending_for_delivery = thumbnail_pending.clone();
let window = weak.clone();
app.global::<MailAttachments>().on_deliver(move || {
let Some(app) = window.upgrade() else {
@@ -221,9 +319,43 @@ pub(crate) fn register(
Err(error) => ui.set_status(error.into()),
}
}
while let Ok((message, attachment_id, core, result)) = thumbnail_receiver.try_recv() {
let core_id = Arc::as_ptr(&core) as usize;
pending_for_delivery
.borrow_mut()
.remove(&(core_id, message, attachment_id));
if app.global::<EmailReader>().get_message_id() != message
|| !state
.borrow()
.core
.as_ref()
.is_some_and(|candidate| Arc::ptr_eq(&core, &candidate.file_core()))
{
continue;
}
let Ok((pixels, width, height)) = result else {
continue;
};
let rows = app.global::<MailAttachments>().get_rows();
for index in 0..rows.row_count() {
let Some(mut row) = rows.row_data(index) else {
continue;
};
if row.id.as_str() != attachment_id.to_string() {
continue;
}
row.thumbnail = slint::Image::from_rgba8(
SharedPixelBuffer::<Rgba8Pixel>::clone_from_slice(&pixels, width, height),
);
row.has_thumbnail = true;
rows.set_row_data(index, row);
break;
}
}
});
let state = inbox.clone();
let handle = runtime.handle().clone();
let pending_thumbnails = thumbnail_pending.clone();
app.global::<MailAttachments>()
.on_command(move |action, value| {
let _keep_timer = &timer;
@@ -235,9 +367,6 @@ pub(crate) fn register(
close(&ui, &generation, &session);
return;
}
if ui.get_busy() {
return;
}
let Some(core) = state.borrow().core.as_ref().map(|core| core.file_core()) else {
return;
};
@@ -270,6 +399,28 @@ pub(crate) fn register(
let Some(selected) = selected else {
return;
};
if action == "thumbnail" {
let core_id = Arc::as_ptr(&core) as usize;
if !thumbnail_candidate(&selected)
|| !pending_thumbnails
.borrow_mut()
.insert((core_id, message, selected.id))
{
return;
}
if thumbnail_job_sender
.try_send((message, selected.clone(), core))
.is_err()
{
pending_thumbnails
.borrow_mut()
.remove(&(core_id, message, selected.id));
}
return;
}
if ui.get_busy() {
return;
}
let page = value.parse::<u32>().unwrap_or(0);
if action == "page" && page >= ui.get_pages().max(0) as u32 {
return;
@@ -412,5 +563,8 @@ mod tests {
assert_eq!(rows[0].id.as_str(), i64::MAX.to_string());
assert!(rows[0].previewable && rows[1].previewable);
assert!(!rows[2].previewable);
assert!(!thumbnail_candidate(&files[0]));
assert!(thumbnail_candidate(&files[1]));
assert!(!rows[1].has_thumbnail);
}
}
+50
View File
@@ -3,6 +3,7 @@ use std::sync::Arc;
// Cancelling spawn_blocking does not stop an active decoder. Keep admission
// inside the worker until decoding really ends, and never queue more images.
static IMAGE_SLOT: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(1);
static THUMBNAIL_SLOT: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(1);
type Result<T> = std::result::Result<T, String>;
pub(crate) enum Preview {
Text(String),
@@ -77,9 +78,58 @@ async fn decode_image(work: impl FnOnce() -> Result<Preview> + Send + 'static) -
.map_err(|e| e.to_string())?
}
/// Decode a small attachment thumbnail on a separate, serialized lane so
/// background list decoration cannot make an explicitly requested preview
/// report that its decoder is busy.
pub(crate) async fn image_thumbnail(data: Vec<u8>) -> Result<(Vec<u8>, u32, u32)> {
if data.len() > 4 * 1024 * 1024 {
return Err("Image is too large for an attachment thumbnail".into());
}
let permit = THUMBNAIL_SLOT.acquire().await.map_err(|e| e.to_string())?;
tokio::task::spawn_blocking(move || {
let _permit = permit;
let mut reader = image::ImageReader::new(std::io::Cursor::new(data))
.with_guessed_format()
.map_err(|e| e.to_string())?;
let mut limits = image::Limits::default();
limits.max_image_width = Some(4096);
limits.max_image_height = Some(4096);
limits.max_alloc = Some(64 * 1024 * 1024);
reader.limits(limits);
let image = reader
.decode()
.map_err(|_| "Could not decode attachment thumbnail".to_string())?
.thumbnail(96, 96)
.into_rgba8();
let (width, height) = image.dimensions();
Ok((image.into_raw(), width, height))
})
.await
.map_err(|e| e.to_string())?
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn attachment_thumbnail_is_bounded_and_preserves_aspect_ratio() {
let source = image::RgbaImage::from_pixel(200, 100, image::Rgba([24, 120, 176, 255]));
let mut encoded = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(source)
.write_to(&mut encoded, image::ImageFormat::Png)
.unwrap();
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let (pixels, width, height) = runtime
.block_on(image_thumbnail(encoded.into_inner()))
.unwrap();
assert_eq!((width, height), (96, 48));
assert_eq!(pixels.len(), (width * height * 4) as usize);
}
#[test]
fn cancelling_a_preview_does_not_admit_another_decoder_until_the_worker_exits() {
let runtime = tokio::runtime::Builder::new_multi_thread()
+1 -1
View File
@@ -185,7 +185,6 @@ pub(super) fn apply_email(
reader.set_body_pending(email.body_pending);
reader.set_authored_text(email.text.clone().unwrap_or_default().into());
let same_message = reader.get_message_id() == email.id;
crate::attachment_controller::project(app, &email, same_message);
let mut scroll = if same_message {
app.get_email_scroll_y()
} else {
@@ -202,6 +201,7 @@ pub(super) fn apply_email(
reader.set_notice("".into());
reader.set_destination("".into());
}
crate::attachment_controller::project(app, &email, same_message);
app.set_email_scroll_y(scroll);
let preview = display_preview(&email.preview);
+31 -9
View File
@@ -312,15 +312,24 @@ fn automatic_selection_hydrates_and_reader_controls_are_responsive_and_keyboard_
assert!(app.get_has_selection());
// Hydrated attachments must survive a body-rendering failure and remain
// projected independently of the HTML/plain-text reader.
state.borrow_mut().messages[0].attachments = vec![flectar_mail_core::models::AttachmentMeta {
id: 42,
filename: Some("Project overview.pdf".into()),
mime_type: Some("application/pdf".into()),
size: Some(1024),
is_inline: false,
}];
state.borrow_mut().messages[0].attachments = vec![
flectar_mail_core::models::AttachmentMeta {
id: 42,
filename: Some("Project overview.pdf".into()),
mime_type: Some("application/pdf".into()),
size: Some(1024),
is_inline: false,
},
flectar_mail_core::models::AttachmentMeta {
id: 43,
filename: Some("email-logo.png".into()),
mime_type: Some("image/png".into()),
size: Some(512),
is_inline: false,
},
];
render_current(&app, &state, &runtime).unwrap();
assert_eq!(app.global::<MailAttachments>().get_rows().row_count(), 1);
assert_eq!(app.global::<MailAttachments>().get_rows().row_count(), 2);
// A structural rejection while the reader is open must never leave the
// previous message's accessible content visible over the fallback.
state.borrow_mut().messages[0].html = Some("<span>x</span>".repeat(16_000));
@@ -335,7 +344,16 @@ fn automatic_selection_hydrates_and_reader_controls_are_responsive_and_keyboard_
!app.get_selected_plain_text()
.contains("Reader verification")
);
assert_eq!(app.global::<MailAttachments>().get_rows().row_count(), 1);
assert_eq!(app.global::<MailAttachments>().get_rows().row_count(), 2);
let attachment_rows = app.global::<MailAttachments>().get_rows();
let mut image_attachment = attachment_rows.row_data(1).unwrap();
let mut thumbnail = slint::SharedPixelBuffer::<slint::Rgba8Pixel>::new(96, 64);
for (index, pixel) in thumbnail.make_mut_slice().iter_mut().enumerate() {
*pixel = slint::Rgba8Pixel::new(24, 120 + (index % 96) as u8, 176, 255);
}
image_attachment.thumbnail = slint::Image::from_rgba8(thumbnail);
image_attachment.has_thumbnail = true;
attachment_rows.set_row_data(1, image_attachment);
app.set_theme_mode("light".into());
draw("mail-attachments-fallback", 1280, 900);
draw("mail-attachments-landscape", 844, 390);
@@ -343,6 +361,10 @@ fn automatic_selection_hydrates_and_reader_controls_are_responsive_and_keyboard_
app.get_email_viewport_height() >= 40.0,
"Short windows must retain room for the body while attachments remain available in the toolbar"
);
app.set_text_mode(false);
app.set_email_content_aspect(8.0);
app.global::<EmailReader>().invoke_command("end".into());
draw("mail-long-message-actions", 1280, 900);
// Exercise the same percentage editor in the attachment dialog. A wide
// bitmap verifies fit on resize and manual zoom preservation without a
+1
View File
@@ -299,6 +299,7 @@ pub(super) fn register_renderer_input_callbacks(
* app.get_email_viewport_width()
* reader.get_width_ratio()
+ banner_height(&app)
+ app.get_email_scroll_tail_height()
- height)
.max(0.0);
let y = match command {
+2
View File
@@ -707,6 +707,7 @@ export component AppWindow inherits Window {
// callback and reflows Blitz at the current logical viewport size.
in-out property <length> email-viewport-width: 0px;
in-out property <length> email-viewport-height: 0px;
in-out property <length> email-scroll-tail-height: 0px;
in-out property <length> email-scroll-y: 0px;
in-out property <bool> settings_open: false;
changed settings_open => { root.product-changed(root.active_view); }
@@ -1044,6 +1045,7 @@ export component AppWindow inherits Window {
MailView {
changed email-viewport-width => { root.email-viewport-width = self.email-viewport-width; }
changed email-viewport-height => { root.email-viewport-height = self.email-viewport-height; }
changed email-scroll-tail-height => { root.email-scroll-tail-height = self.email-scroll-tail-height; }
compact-page <=> root.mail-compact-page;
folders-open <=> root.mail-folders-open;
loading: !root.startup_hydrated || root.mail_page_loading;
+8 -1
View File
@@ -360,7 +360,14 @@ export global EmailReader {
in-out property <bool> use-authored-text: false;
}
export struct MailAttachment { id: string, name: string, detail: string, previewable: bool }
export struct MailAttachment {
id: string,
name: string,
detail: string,
previewable: bool,
thumbnail: image,
has_thumbnail: bool,
}
export global MailAttachments {
callback deliver();
in-out property <[MailAttachment]> rows;
+161 -95
View File
@@ -3,7 +3,6 @@ import { ActionMenu, ActionMenuEntry } from "../components/action-menu.slint";
import { ZoomControls } from "../components/zoom-controls.slint";
import { Button as ReaderButton, CheckBox, HorizontalBox, ListView, ScrollView, VerticalBox } from "std-widgets.slint";
import { MailAttachments, EmailReader, EmailLink, EmailTile, MailLabelRow, ThreadMessageRow } from "../models.slint";
import { SidebarItem } from "../components/sidebar-controls.slint";
import { Avatar } from "../components/avatar.slint";
import { Button } from "../components/button.slint";
import { IconButton, LineEdit, TextEdit, ToolbarIconButton } from "../components/controls.slint";
@@ -407,6 +406,7 @@ export component MailReadingPane inherits Rectangle {
: root.rendered-email-height;
private property <length> attachment-flow-y: root.message-body-content-height;
private property <length> message-actions-height: root.compact ? 52px : 42px;
private property <length> message-bottom-padding: root.compact ? 10px : 12px;
private property <length> thread-header-height: 64px;
private property <length> thread-item-stride: root.thread-header-height + root.thread-item-gap;
private property <int> accordion-message-count: max(1, root.thread-messages.length);
@@ -420,7 +420,12 @@ export component MailReadingPane inherits Rectangle {
private property <length> message-actions-y: root.message-body-y + root.message-document-height;
private property <length> expanded-message-height: root.renderer-controls-height
+ root.message-document-height
+ root.message-actions-height;
+ root.message-actions-height
+ root.message-bottom-padding;
out property <length> email-scroll-tail-height: max(
0px,
root.message-document-height - root.rendered-email-height
) + root.message-actions-height + root.message-bottom-padding;
animate selected-header-y { duration: 240ms; easing: spring(0); }
in property <color> accent;
in property <color> accent-soft;
@@ -1471,19 +1476,82 @@ export component MailReadingPane inherits Rectangle {
}
reject
}
SidebarItem {
Rectangle {
width: parent.width;
row-height: 44px;
left-padding: 8px;
label: attachment.name;
selected: attachment-focus.has-focus;
icon: @image-url("../icons/lucide/outline/paperclip.svg");
sidebar-background: root.surface;
selection-background: root.accent-soft;
accent-color: root.accent;
body-color: root.heading;
muted-color: root.secondary-text;
clicked(scope) => { attachment-focus.focus(); MailAttachments.command(attachment.previewable ? "preview" : "download", attachment.id); }
height: 44px;
border-radius: 7px;
background: attachment-hit.has-hover || attachment-focus.has-focus
? root.accent-soft
: transparent;
Rectangle {
x: 4px;
y: 4px;
width: 36px;
height: 36px;
border-radius: 6px;
border-width: 1px;
border-color: root.border-subtle;
background: root.control-bg;
clip: true;
if attachment.has_thumbnail : Image {
width: parent.width;
height: parent.height;
source: attachment.thumbnail;
image-fit: cover;
accessible-role: none;
}
if !attachment.has_thumbnail : Image {
x: 9px;
y: 9px;
width: 18px;
height: 18px;
source: @image-url("../icons/lucide/outline/file.svg");
colorize: root.secondary-text;
image-fit: contain;
accessible-role: none;
}
}
Text {
x: 48px;
y: attachment.detail == "" ? 0px : 3px;
width: max(0px, parent.width - self.x - 6px);
height: attachment.detail == "" ? parent.height : 22px;
text: attachment.name;
color: root.heading;
font-size: TypeScale.body;
font-weight: attachment-focus.has-focus ? 600 : 500;
overflow: elide;
vertical-alignment: center;
}
if attachment.detail != "" : Text {
x: 48px;
y: 23px;
width: max(0px, parent.width - self.x - 6px);
height: 17px;
text: attachment.detail;
color: root.secondary-text;
font-size: TypeScale.caption;
overflow: elide;
vertical-alignment: center;
}
attachment-hit := TouchArea {
accessible-role: button;
accessible-label: attachment.name;
accessible-description: attachment.detail;
accessible-action-default => {
attachment-focus.focus();
MailAttachments.command(attachment.previewable ? "preview" : "download", attachment.id);
}
clicked => {
attachment-focus.focus();
MailAttachments.command(attachment.previewable ? "preview" : "download", attachment.id);
}
mouse-cursor: pointer;
}
}
}
ToolbarIconButton {
@@ -1496,16 +1564,15 @@ export component MailReadingPane inherits Rectangle {
}
}
}
if EmailReader.reader-mode : Rectangle {
x: -email-scroll.content-x + root.message-card-inset + root.message-card-border-width;
y: root.message-body-y;
width: root.email-viewport-width; height: root.message-body-content-height;
background: EmailReader.dark-reader ? #171a21 : root.surface;
reader-list := ListView {
width: parent.width; height: parent.height;
content-width: self.visible-width;
for item in EmailReader.items : VerticalBox {
if EmailReader.reader-mode : Rectangle {
x: -email-scroll.content-x + root.message-card-inset + root.message-card-border-width;
y: root.message-body-y;
width: root.email-viewport-width; height: root.message-body-content-height;
background: EmailReader.dark-reader ? #171a21 : root.surface;
reader-list := ListView {
width: parent.width; height: parent.height;
content-width: self.visible-width;
for item in EmailReader.items : VerticalBox {
width: reader-list.visible-width;
padding: 16px; spacing: 0px;
if item.url != "" : ReaderButton {
@@ -1520,84 +1587,83 @@ export component MailReadingPane inherits Rectangle {
accessible-role: text;
accessible-label: item.kind == "heading" ? @tr("Heading: {}", item.name) : item.name;
}
}
}
}
}
// Conversation actions belong to the expanded
// message and follow its real content height. This
// keeps short notifications compact while long
// messages reveal the actions at their natural end.
message-actions := Rectangle {
x: -email-scroll.content-x + root.message-card-inset + root.message-card-border-width;
y: root.message-actions-y;
width: root.email-viewport-width;
height: root.message-actions-height;
background: root.surface;
// These actions are the natural end of the selected
// message, after its body and attachments. The
// ScrollView content height above includes this row
// and a small reveal margin beneath it.
message-actions := Rectangle {
x: -email-scroll.content-x + root.message-card-inset + root.message-card-border-width;
y: root.message-actions-y;
width: root.email-viewport-width;
height: root.message-actions-height;
background: root.surface;
Rectangle {
x: 0px;
y: 0px;
width: parent.width;
height: 1px;
background: root.border-subtle;
}
HorizontalBox {
padding-top: root.compact ? 4px : 5px;
padding-right: 10px;
padding-bottom: root.compact ? 4px : 5px;
padding-left: 10px;
spacing: 4px;
alignment: start;
cross-axis-alignment: center;
if !root.selected_is_draft : ReaderControl {
compact: root.compact;
interactive: root.has_selected;
icon: @image-url("../icons/lucide/outline/reply.svg");
tooltip-text: @tr("Reply");
accessible-id: "message-reply-button";
clicked => { root.compose_message_action("reply"); }
}
if !root.selected_is_draft : ReaderControl {
compact: root.compact;
interactive: root.has_selected;
icon: @image-url("../icons/lucide/outline/reply-all.svg");
tooltip-text: @tr("Reply all");
accessible-id: "message-reply-all-button";
clicked => { root.compose_message_action("reply_all"); }
}
if !root.selected_is_draft : ReaderControl {
compact: root.compact;
interactive: root.has_selected;
icon: @image-url("../icons/lucide/outline/forward.svg");
tooltip-text: @tr("Forward");
accessible-id: "message-forward-button";
clicked => { root.compose_message_action("forward"); }
}
if root.selected_is_draft : ReaderControl {
compact: root.compact;
interactive: root.has_selected;
icon: @image-url("../icons/lucide/filled/compose.svg");
tooltip-text: @tr("Continue editing");
clicked => { root.compose_message_action("edit_draft"); }
Rectangle {
width: parent.width;
height: 1px;
background: root.border-subtle;
}
Rectangle { horizontal-stretch: 1; }
HorizontalBox {
padding-top: root.compact ? 4px : 5px;
padding-right: 10px;
padding-bottom: root.compact ? 4px : 5px;
padding-left: 10px;
spacing: 4px;
alignment: start;
cross-axis-alignment: center;
if !root.compact && !root.selected_is_draft : ReaderControl {
interactive: root.has_selected;
icon: @image-url("../icons/lucide/outline/browser.svg");
tooltip-text: @tr("Open in browser");
accessible-id: "message-open-in-browser-button";
clicked => { root.open_message_in_browser(); }
}
if !root.compact && !root.selected_is_draft : ReaderControl {
interactive: root.has_selected;
icon: @image-url("../icons/lucide/outline/info.svg");
tooltip-text: @tr("About email rendering");
clicked => { root.rendering_info_open = true; }
if !root.selected_is_draft : ReaderControl {
compact: root.compact;
interactive: root.has_selected;
icon: @image-url("../icons/lucide/outline/reply.svg");
tooltip-text: @tr("Reply");
accessible-id: "message-reply-button";
clicked => { root.compose_message_action("reply"); }
}
if !root.selected_is_draft : ReaderControl {
compact: root.compact;
interactive: root.has_selected;
icon: @image-url("../icons/lucide/outline/reply-all.svg");
tooltip-text: @tr("Reply all");
accessible-id: "message-reply-all-button";
clicked => { root.compose_message_action("reply_all"); }
}
if !root.selected_is_draft : ReaderControl {
compact: root.compact;
interactive: root.has_selected;
icon: @image-url("../icons/lucide/outline/forward.svg");
tooltip-text: @tr("Forward");
accessible-id: "message-forward-button";
clicked => { root.compose_message_action("forward"); }
}
if root.selected_is_draft : ReaderControl {
compact: root.compact;
interactive: root.has_selected;
icon: @image-url("../icons/lucide/filled/compose.svg");
tooltip-text: @tr("Continue editing");
clicked => { root.compose_message_action("edit_draft"); }
}
Rectangle { horizontal-stretch: 1; }
if !root.compact && !root.selected_is_draft : ReaderControl {
interactive: root.has_selected;
icon: @image-url("../icons/lucide/outline/browser.svg");
tooltip-text: @tr("Open in browser");
accessible-id: "message-open-in-browser-button";
clicked => { root.open_message_in_browser(); }
}
if !root.compact && !root.selected_is_draft : ReaderControl {
interactive: root.has_selected;
icon: @image-url("../icons/lucide/outline/info.svg");
tooltip-text: @tr("About email rendering");
clicked => { root.rendering_info_open = true; }
}
}
}
}
+1
View File
@@ -81,6 +81,7 @@ export component MailView inherits Rectangle {
: min(96px, root.single-pane-content-width);
out property <length> email-viewport-width: root.single-pane ? compact-reading-pane.email-viewport-width : desktop-reading-pane.email-viewport-width;
out property <length> email-viewport-height: root.single-pane ? compact-reading-pane.email-viewport-height : desktop-reading-pane.email-viewport-height;
out property <length> email-scroll-tail-height: root.single-pane ? compact-reading-pane.email-scroll-tail-height : desktop-reading-pane.email-scroll-tail-height;
in property <color> accent;
in property <color> accent-soft;
in property <color> avatar-bg;