mirror of
https://github.com/flectar/mail.git
synced 2026-11-11 17:57:54 +00:00
fix: refine message reader
This commit is contained in:
1 parent
37fb3836a2
commit
2cc256078e
9 files changed
+415
-112
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user