mirror of
https://github.com/flectar/mail.git
synced 2026-11-13 10:34:15 +00:00
chore: merge main
This commit is contained in:
commit
1a086b52ad
11 files changed
+1144
-443
No files matched your search
@@ -0,0 +1,42 @@
|
||||
name: Email rendering issue
|
||||
description: An email looks wrong or part of it doesn't work in Flectar Mail.
|
||||
body:
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: What looks wrong?
|
||||
description: >-
|
||||
Describe what's missing, broken or not working. A screenshot helps, and
|
||||
one of the same email in another mail app helps even more.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: html
|
||||
attributes:
|
||||
label: Email HTML
|
||||
description: >-
|
||||
Report issue in Flectar Mail copies the email's HTML for you, or saves
|
||||
it as a file when it's too long for GitHub. Paste it inside the code
|
||||
block or attach the file. Replace names, email addresses and personal
|
||||
links with placeholders first.
|
||||
value: |
|
||||
<details><summary>Email HTML</summary>
|
||||
|
||||
```html
|
||||
|
||||
```
|
||||
|
||||
</details>
|
||||
- type: textarea
|
||||
id: environment
|
||||
attributes:
|
||||
label: Version and system
|
||||
description: >-
|
||||
Report issue in Flectar Mail fills this in. Otherwise, add the version
|
||||
shown in Settings and your system.
|
||||
placeholder: |
|
||||
Flectar Mail 0.1.0-alpha.7 (AppImage)
|
||||
Linux x86_64 (Arch Linux)
|
||||
GPU renderer, 100% zoom, light message colors
|
||||
validations:
|
||||
required: true
|
||||
@@ -30,6 +30,29 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
- name: Free runner disk space
|
||||
# This job peaks at about 33 GiB of disk. Linux runners only promise
|
||||
# 14 GB, and some now have a 72 GB disk instead of 145 GB, which leaves
|
||||
# too little (actions/runner-images#15238). On those, delete
|
||||
# preinstalled SDKs, browsers and Docker images the job never uses.
|
||||
shell: bash
|
||||
run: |
|
||||
df -h /
|
||||
if (( $(df -k --output=avail / | tail -1) < 50 * 1024 * 1024 )); then
|
||||
sudo rm -rf \
|
||||
/usr/local/lib/android \
|
||||
/usr/share/dotnet \
|
||||
/usr/local/.ghcup \
|
||||
/usr/share/swift \
|
||||
"${RUNNER_TOOL_CACHE:?}/CodeQL" \
|
||||
/usr/local/share/powershell \
|
||||
/opt/microsoft \
|
||||
/usr/local/julia* \
|
||||
/usr/local/share/chromium
|
||||
sudo docker image prune --all --force > /dev/null ||
|
||||
echo "::warning::Could not remove the preinstalled Docker images."
|
||||
df -h /
|
||||
fi
|
||||
- name: Install native build dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
|
||||
+450
-438
File diff suppressed because it is too large.
Load diff
+8
@@ -68,6 +68,13 @@ public final class NativeCallbacksTest {
|
||||
invoke("nativeSuspendPdfPreview", new Class<?>[]{});
|
||||
}
|
||||
|
||||
@Test
|
||||
public void trimMemoryCallbackResolves() throws Throwable {
|
||||
// TRIM_MEMORY_RUNNING_MODERATE (5) asks for nothing, so the callback
|
||||
// only maps the level and never reaches the UI event loop.
|
||||
invoke("nativeTrimMemory", new Class<?>[]{int.class, int.class}, 5, 34);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void everyNativeDeclarationHasACallbackTest() {
|
||||
Set<String> declarations = new HashSet<>();
|
||||
@@ -83,6 +90,7 @@ public final class NativeCallbacksTest {
|
||||
tested.add("nativeDocumentResult");
|
||||
tested.add("nativeDocumentsResult");
|
||||
tested.add("nativeSuspendPdfPreview");
|
||||
tested.add("nativeTrimMemory");
|
||||
assertEquals("Add a JNI invocation test when adding a native callback", tested, declarations);
|
||||
}
|
||||
}
|
||||
+16
@@ -63,6 +63,7 @@ mod remote;
|
||||
mod renderer;
|
||||
mod renderer_input_controller;
|
||||
mod renderer_preferences;
|
||||
mod rendering_report;
|
||||
mod retained_model;
|
||||
mod rich_compose;
|
||||
mod settings_controller;
|
||||
@@ -7835,6 +7836,21 @@ pub fn run(platform: PlatformContext) -> Result<(), Box<dyn std::error::Error>>
|
||||
use_wgpu,
|
||||
platform.external_urls.clone(),
|
||||
);
|
||||
let state_for_report = Rc::clone(&state);
|
||||
rendering_report::register(
|
||||
&app,
|
||||
move || {
|
||||
state_for_report
|
||||
.borrow()
|
||||
.core
|
||||
.as_ref()
|
||||
.map(|core| core.file_core())
|
||||
},
|
||||
&runtime,
|
||||
platform.documents.clone(),
|
||||
platform.paths.temp_dir(),
|
||||
platform.external_urls.clone(),
|
||||
);
|
||||
|
||||
let app_weak = app.as_weak();
|
||||
let state_for_search = Rc::clone(&state);
|
||||
|
||||
@@ -324,7 +324,7 @@ pub(super) fn register_renderer_input_callbacks(
|
||||
}
|
||||
});
|
||||
}
|
||||
fn copy_text(app: &AppWindow, text: String) {
|
||||
pub(super) fn copy_text(app: &AppWindow, text: String) {
|
||||
app.set_clipboard_request(text.into());
|
||||
app.set_clipboard_request_id(app.get_clipboard_request_id().wrapping_add(1));
|
||||
}
|
||||
@@ -0,0 +1,576 @@
|
||||
//! Report issue in About email rendering. It opens the GitHub form for
|
||||
//! rendering problems with this copy's version and reader settings filled in
|
||||
//! and hands over the email's HTML for the reporter to review and include,
|
||||
//! the way Zed and VS Code prefill their bug reports.
|
||||
use super::*;
|
||||
use crate::documents::{DocumentDialog, DocumentFuture, DocumentProvider};
|
||||
use flectar_mail_core::Core;
|
||||
use std::{io::Write, path::Path};
|
||||
|
||||
/// The form in .github/ISSUE_TEMPLATE/email-rendering.yml. GitHub fills a
|
||||
/// form field from the query parameter named after the field's `id`.
|
||||
const ISSUE_FORM: &str = "https://github.com/flectar/mail/issues/new?template=email-rendering.yml";
|
||||
|
||||
/// GitHub rejects issues longer than 65,536 characters. HTML up to this
|
||||
/// length goes on the clipboard and leaves room for the rest of the form.
|
||||
/// Longer HTML is saved as a file to attach, as VS Code does with data too
|
||||
/// long for an issue.
|
||||
const PASTE_LIMIT: usize = 60_000;
|
||||
|
||||
/// Attachments show their file name on the issue, so it never carries the
|
||||
/// subject.
|
||||
const FILE_NAME: &str = "email.html";
|
||||
|
||||
/// `core` returns the open mail data, whose settings choose the folder a
|
||||
/// saved file starts in.
|
||||
pub(super) fn register(
|
||||
app: &AppWindow,
|
||||
core: impl Fn() -> Option<Arc<Core>> + 'static,
|
||||
runtime: &tokio::runtime::Handle,
|
||||
documents: Arc<dyn DocumentProvider>,
|
||||
temp_dir: PathBuf,
|
||||
opener: ExternalUrlOpener,
|
||||
) {
|
||||
let weak = app.as_weak();
|
||||
let runtime = runtime.clone();
|
||||
let saving = Rc::new(Cell::new(false));
|
||||
app.on_report_rendering_issue(move || {
|
||||
let Some(app) = weak.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let url = issue_url(&Environment::from_app(&app).describe());
|
||||
let html = app.get_selected_source();
|
||||
if fits_in_issue(&html) {
|
||||
if !html.is_empty() {
|
||||
crate::renderer_input_controller::copy_text(&app, html.into());
|
||||
app.set_render_status(UiMessage::plain(
|
||||
"Copied HTML source. Paste it into the issue.",
|
||||
));
|
||||
}
|
||||
open_form(&app, &opener, &url);
|
||||
return;
|
||||
}
|
||||
// The browser would cover a save dialog opened after it, so the form
|
||||
// opens once the file is saved.
|
||||
if saving.replace(true) {
|
||||
return;
|
||||
}
|
||||
let request = save_html(
|
||||
core(),
|
||||
Arc::clone(&documents),
|
||||
temp_dir.clone(),
|
||||
html.into(),
|
||||
);
|
||||
let weak = app.as_weak();
|
||||
let opener = opener.clone();
|
||||
let saving = Rc::clone(&saving);
|
||||
crate::documents::run_request(&runtime, request, move |result| {
|
||||
saving.set(false);
|
||||
if let Some(app) = weak.upgrade() {
|
||||
finish_saving(&app, &opener, &url, result);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn open_form(app: &AppWindow, opener: &ExternalUrlOpener, url: &str) {
|
||||
if let Err(error) = opener.open_external(url) {
|
||||
app.set_render_status(UiMessage::detail("Could not open link: {}", error));
|
||||
}
|
||||
}
|
||||
|
||||
fn fits_in_issue(html: &str) -> bool {
|
||||
html.chars().count() <= PASTE_LIMIT
|
||||
}
|
||||
|
||||
/// Saves HTML too long for an issue as a file for the reporter to attach.
|
||||
fn save_html(
|
||||
core: Option<Arc<Core>>,
|
||||
documents: Arc<dyn DocumentProvider>,
|
||||
temp_dir: PathBuf,
|
||||
html: String,
|
||||
) -> DocumentFuture<bool> {
|
||||
Box::pin(async move {
|
||||
let file = tokio::task::spawn_blocking(move || stage(&temp_dir, &html))
|
||||
.await
|
||||
.map_err(|error| error.to_string())?
|
||||
.map_err(|error| error.to_string())?;
|
||||
// Start in the download folder from Settings, as attachments do.
|
||||
let dialog = match core {
|
||||
Some(core) => crate::documents::download_dialog_for(&core).await,
|
||||
None => DocumentDialog::default(),
|
||||
};
|
||||
let dialog = DocumentDialog {
|
||||
title: "Save email HTML".into(),
|
||||
filter: Some(("HTML".into(), &["html"])),
|
||||
..dialog
|
||||
};
|
||||
documents
|
||||
.export_with(file.to_path_buf(), FILE_NAME.into(), dialog)
|
||||
.await
|
||||
})
|
||||
}
|
||||
|
||||
fn finish_saving(
|
||||
app: &AppWindow,
|
||||
opener: &ExternalUrlOpener,
|
||||
url: &str,
|
||||
result: Result<bool, String>,
|
||||
) {
|
||||
match result {
|
||||
Ok(true) => app.set_render_status(UiMessage::plain(
|
||||
"Saved HTML source. Attach the file to the issue.",
|
||||
)),
|
||||
// Cancelled. The form still opens for a report without the HTML.
|
||||
Ok(false) => {}
|
||||
Err(error) => {
|
||||
app.set_render_status(UiMessage::detail("Could not save HTML source: {}", error))
|
||||
}
|
||||
}
|
||||
open_form(app, opener, url);
|
||||
}
|
||||
|
||||
/// Writes the HTML below the cache directory, which every document provider
|
||||
/// accepts as an export source. The file goes away when the path drops.
|
||||
fn stage(temp_dir: &Path, html: &str) -> std::io::Result<tempfile::TempPath> {
|
||||
std::fs::create_dir_all(temp_dir)?;
|
||||
let mut file = tempfile::Builder::new()
|
||||
.prefix("email-")
|
||||
.suffix(".html")
|
||||
.tempfile_in(temp_dir)?;
|
||||
file.write_all(html.as_bytes())?;
|
||||
Ok(file.into_temp_path())
|
||||
}
|
||||
|
||||
fn issue_url(environment: &str) -> String {
|
||||
let mut url = url::Url::parse(ISSUE_FORM).expect("the issue form address is valid");
|
||||
url.query_pairs_mut()
|
||||
.append_pair("environment", environment);
|
||||
url.into()
|
||||
}
|
||||
|
||||
/// What a report says about this copy of the app and the reader's settings.
|
||||
struct Environment {
|
||||
version: &'static str,
|
||||
package: Option<&'static str>,
|
||||
system: &'static str,
|
||||
architecture: &'static str,
|
||||
distribution: Option<String>,
|
||||
gpu: bool,
|
||||
zoom: f32,
|
||||
fit: bool,
|
||||
colors: &'static str,
|
||||
remote_images_blocked: bool,
|
||||
}
|
||||
|
||||
impl Environment {
|
||||
fn from_app(app: &AppWindow) -> Self {
|
||||
let reader = app.global::<EmailReader>();
|
||||
let colors = app.global::<MessageColors>();
|
||||
Self {
|
||||
version: env!("CARGO_PKG_VERSION"),
|
||||
package: package(),
|
||||
system: system(),
|
||||
architecture: std::env::consts::ARCH,
|
||||
distribution: distribution(),
|
||||
gpu: app.global::<RendererSettings>().get_active() == "gpu",
|
||||
zoom: reader.get_zoom(),
|
||||
fit: reader.get_auto_fit(),
|
||||
colors: if colors.get_original() {
|
||||
"original"
|
||||
} else if colors.get_dark() {
|
||||
"dark"
|
||||
} else {
|
||||
"light"
|
||||
},
|
||||
remote_images_blocked: app.get_remote_images_blocked(),
|
||||
}
|
||||
}
|
||||
|
||||
fn describe(&self) -> String {
|
||||
let package = self
|
||||
.package
|
||||
.map(|package| format!(" ({package})"))
|
||||
.unwrap_or_default();
|
||||
let distribution = self
|
||||
.distribution
|
||||
.as_ref()
|
||||
.map(|distribution| format!(" ({distribution})"))
|
||||
.unwrap_or_default();
|
||||
let renderer = if self.gpu { "GPU" } else { "CPU" };
|
||||
let fit = if self.fit { " (fit to width)" } else { "" };
|
||||
let images = if self.remote_images_blocked {
|
||||
", remote images blocked"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
format!(
|
||||
"Flectar Mail {}{package}\n{} {}{distribution}\n{renderer} renderer, {:.0}% zoom{fit}, {} message colors{images}",
|
||||
self.version,
|
||||
self.system,
|
||||
self.architecture,
|
||||
self.zoom * 100.0,
|
||||
self.colors,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn system() -> &'static str {
|
||||
#[cfg(mobile)]
|
||||
let name = crate::platform::NAME;
|
||||
#[cfg(desktop)]
|
||||
let name = std::env::consts::OS;
|
||||
match name {
|
||||
"linux" => "Linux",
|
||||
"windows" => "Windows",
|
||||
"macos" => "macOS",
|
||||
"android" => "Android",
|
||||
"ios" => "iOS",
|
||||
"ohos" => "OpenHarmony",
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// Linux packages bring their own libraries and sandboxes, which change how
|
||||
/// links, fonts and files behave.
|
||||
fn package() -> Option<&'static str> {
|
||||
if cfg!(feature = "flatpak") {
|
||||
return Some("Flatpak");
|
||||
}
|
||||
#[cfg(linux_desktop)]
|
||||
if std::env::var_os("APPIMAGE").is_some() {
|
||||
return Some("AppImage");
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(linux_desktop)]
|
||||
fn distribution() -> Option<String> {
|
||||
// Inside Flatpak /etc/os-release names the runtime. Flatpak mounts the
|
||||
// host's file at /run/host/os-release, as os-release(5) suggests.
|
||||
let files: &[&str] = if cfg!(feature = "flatpak") {
|
||||
&["/run/host/os-release"]
|
||||
} else {
|
||||
&["/etc/os-release", "/usr/lib/os-release"]
|
||||
};
|
||||
// os-release(5): use only the first file that exists.
|
||||
files
|
||||
.iter()
|
||||
.find_map(|path| std::fs::read_to_string(path).ok())
|
||||
.and_then(|text| pretty_name(&text))
|
||||
}
|
||||
|
||||
#[cfg(not(linux_desktop))]
|
||||
fn distribution() -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// `PRETTY_NAME` from an os-release file, whose values follow shell quoting.
|
||||
#[cfg(any(linux_desktop, test))]
|
||||
fn pretty_name(os_release: &str) -> Option<String> {
|
||||
let value = os_release
|
||||
.lines()
|
||||
.find_map(|line| line.trim().strip_prefix("PRETTY_NAME="))?
|
||||
.trim();
|
||||
let value = ['"', '\'']
|
||||
.into_iter()
|
||||
.find_map(|quote| value.strip_prefix(quote)?.strip_suffix(quote))
|
||||
.unwrap_or(value);
|
||||
let mut name = String::with_capacity(value.len());
|
||||
let mut characters = value.chars();
|
||||
while let Some(character) = characters.next() {
|
||||
match character {
|
||||
'\\' => name.extend(characters.next()),
|
||||
character => name.push(character),
|
||||
}
|
||||
}
|
||||
let name = name.trim();
|
||||
(!name.is_empty()).then(|| name.to_owned())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use slint::platform::{
|
||||
Platform, WindowAdapter, WindowEvent,
|
||||
software_renderer::{MinimalSoftwareWindow, RepaintBufferType},
|
||||
};
|
||||
use std::{sync::Mutex, time::Duration};
|
||||
|
||||
fn environment() -> Environment {
|
||||
Environment {
|
||||
version: "0.1.0-alpha.7",
|
||||
package: Some("AppImage"),
|
||||
system: "Linux",
|
||||
architecture: "x86_64",
|
||||
distribution: Some("Arch Linux".into()),
|
||||
gpu: true,
|
||||
zoom: 0.85,
|
||||
fit: true,
|
||||
colors: "dark",
|
||||
remote_images_blocked: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn describes_the_app_system_and_reader() {
|
||||
assert_eq!(
|
||||
environment().describe(),
|
||||
"Flectar Mail 0.1.0-alpha.7 (AppImage)\n\
|
||||
Linux x86_64 (Arch Linux)\n\
|
||||
GPU renderer, 85% zoom (fit to width), dark message colors, remote images blocked"
|
||||
);
|
||||
let plain = Environment {
|
||||
package: None,
|
||||
system: "Windows",
|
||||
distribution: None,
|
||||
gpu: false,
|
||||
zoom: 1.25,
|
||||
fit: false,
|
||||
colors: "original",
|
||||
remote_images_blocked: false,
|
||||
..environment()
|
||||
};
|
||||
assert_eq!(
|
||||
plain.describe(),
|
||||
"Flectar Mail 0.1.0-alpha.7\n\
|
||||
Windows x86_64\n\
|
||||
CPU renderer, 125% zoom, original message colors"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefills_the_form_field_named_environment() {
|
||||
let environment = environment().describe();
|
||||
let url = issue_url(&environment);
|
||||
let parsed = url::Url::parse(&url).unwrap();
|
||||
assert_eq!(parsed.host_str(), Some("github.com"));
|
||||
assert_eq!(parsed.path(), "/flectar/mail/issues/new");
|
||||
let query: Vec<(String, String)> = parsed.query_pairs().into_owned().collect();
|
||||
assert_eq!(
|
||||
query,
|
||||
[
|
||||
("template".to_owned(), "email-rendering.yml".to_owned()),
|
||||
("environment".to_owned(), environment),
|
||||
]
|
||||
);
|
||||
// Well below the length VS Code keeps its prefilled issue links under.
|
||||
assert!(url.len() < 7_500, "{}", url.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issue_form_has_the_prefilled_field() {
|
||||
let form = include_str!("../.github/ISSUE_TEMPLATE/email-rendering.yml");
|
||||
let template = ISSUE_FORM.rsplit_once("template=").unwrap().1;
|
||||
assert_eq!(template, "email-rendering.yml");
|
||||
assert!(form.contains("\n id: environment\n"), "{form}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_html_is_saved_instead_of_pasted() {
|
||||
assert!(fits_in_issue(""));
|
||||
assert!(fits_in_issue(&"é".repeat(PASTE_LIMIT)));
|
||||
assert!(!fits_in_issue(&"a".repeat(PASTE_LIMIT + 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_the_pretty_name_from_os_release() {
|
||||
let arch = "NAME=\"Arch Linux\"\nPRETTY_NAME=\"Arch Linux\"\nID=arch\n";
|
||||
assert_eq!(pretty_name(arch).as_deref(), Some("Arch Linux"));
|
||||
assert_eq!(
|
||||
pretty_name("PRETTY_NAME='Debian GNU/Linux 13 (trixie)'").as_deref(),
|
||||
Some("Debian GNU/Linux 13 (trixie)")
|
||||
);
|
||||
assert_eq!(pretty_name("PRETTY_NAME=Gentoo").as_deref(), Some("Gentoo"));
|
||||
assert_eq!(
|
||||
pretty_name(r#"PRETTY_NAME="Say \"hi\" \$HOME""#).as_deref(),
|
||||
Some(r#"Say "hi" $HOME"#)
|
||||
);
|
||||
assert_eq!(pretty_name("NAME=Linux\n"), None);
|
||||
assert_eq!(pretty_name("PRETTY_NAME=\"\""), None);
|
||||
}
|
||||
|
||||
/// Records what an export received while its source file still existed.
|
||||
#[derive(Default)]
|
||||
struct Exports(Mutex<Vec<(PathBuf, String, String, DocumentDialog)>>);
|
||||
|
||||
impl DocumentProvider for Exports {
|
||||
fn import(&self) -> DocumentFuture<Option<crate::documents::ImportedDocument>> {
|
||||
Box::pin(async { Ok(None) })
|
||||
}
|
||||
fn export(&self, path: PathBuf, name: String) -> DocumentFuture<bool> {
|
||||
self.export_with(path, name, DocumentDialog::default())
|
||||
}
|
||||
fn export_with(
|
||||
&self,
|
||||
path: PathBuf,
|
||||
name: String,
|
||||
dialog: DocumentDialog,
|
||||
) -> DocumentFuture<bool> {
|
||||
let content = std::fs::read_to_string(&path).unwrap_or_default();
|
||||
self.0.lock().unwrap().push((path, content, name, dialog));
|
||||
Box::pin(async { Ok(true) })
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saves_long_html_as_a_file_to_attach() {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let temp_dir = directory.path().join("tmp");
|
||||
let exports = Arc::new(Exports::default());
|
||||
let html = format!("<p>{}</p>", "a".repeat(PASTE_LIMIT));
|
||||
let saved = runtime.block_on(save_html(
|
||||
None,
|
||||
exports.clone(),
|
||||
temp_dir.clone(),
|
||||
html.clone(),
|
||||
));
|
||||
assert_eq!(saved, Ok(true));
|
||||
let exports = exports.0.lock().unwrap();
|
||||
let [(path, content, name, dialog)] = exports.as_slice() else {
|
||||
panic!("expected one export");
|
||||
};
|
||||
assert!(path.starts_with(&temp_dir), "{path:?}");
|
||||
assert_eq!(content, &html);
|
||||
assert_eq!(name, FILE_NAME);
|
||||
assert_eq!(dialog.title, "Save email HTML");
|
||||
assert_eq!(dialog.filter, Some(("HTML".to_owned(), &["html"][..])));
|
||||
assert!(!path.exists(), "the staged copy is removed after saving");
|
||||
}
|
||||
|
||||
/// A window with a clipboard and a clock the test moves.
|
||||
struct Headless(
|
||||
Rc<MinimalSoftwareWindow>,
|
||||
Rc<RefCell<String>>,
|
||||
Rc<Cell<Duration>>,
|
||||
);
|
||||
|
||||
impl Platform for Headless {
|
||||
fn create_window_adapter(&self) -> Result<Rc<dyn WindowAdapter>, slint::PlatformError> {
|
||||
Ok(self.0.clone())
|
||||
}
|
||||
fn set_clipboard_text(&self, text: &str, _: slint::platform::Clipboard) {
|
||||
*self.1.borrow_mut() = text.to_owned();
|
||||
}
|
||||
fn duration_since_start(&self) -> Duration {
|
||||
self.2.get()
|
||||
}
|
||||
}
|
||||
|
||||
const SOURCE: &str = "<p>Download your data</p>";
|
||||
|
||||
/// Opens About email rendering in a window `width` wide and presses
|
||||
/// Return, which activates Report issue. Returns the app, the addresses
|
||||
/// it opened and the clipboard.
|
||||
fn report_with_return(width: u32, single_pane: bool) -> (AppWindow, Vec<String>, String) {
|
||||
let window = MinimalSoftwareWindow::new(RepaintBufferType::NewBuffer);
|
||||
let clipboard = Rc::new(RefCell::new(String::new()));
|
||||
let clock = Rc::new(Cell::new(Duration::ZERO));
|
||||
slint::platform::set_platform(Box::new(Headless(
|
||||
window.clone(),
|
||||
clipboard.clone(),
|
||||
clock.clone(),
|
||||
)))
|
||||
.unwrap();
|
||||
let app = AppWindow::new().unwrap();
|
||||
app.set_startup_ready(true);
|
||||
app.set_startup_hydrated(true);
|
||||
app.set_connected_accounts(ModelRc::new(VecModel::from(vec![AccountRow {
|
||||
id: 1,
|
||||
..Default::default()
|
||||
}])));
|
||||
app.set_active_view("mail".into());
|
||||
app.window().set_size(slint::PhysicalSize::new(width, 900));
|
||||
app.show().unwrap();
|
||||
if single_pane {
|
||||
// A tab with a conversation opens the single pane on the message.
|
||||
app.global::<TabsUi>().set_shown_conversation(true);
|
||||
app.global::<TabsUi>().set_shown(1);
|
||||
slint::platform::update_timers_and_animations();
|
||||
// Let the message slide in. The window drops keys aimed at
|
||||
// elements that are still out of view.
|
||||
clock.set(Duration::from_secs(1));
|
||||
slint::platform::update_timers_and_animations();
|
||||
}
|
||||
app.set_selected_source(SOURCE.into());
|
||||
app.set_remote_images_blocked(true);
|
||||
app.global::<RendererSettings>().set_active("gpu".into());
|
||||
app.global::<EmailReader>().set_zoom(0.85);
|
||||
app.global::<EmailReader>().set_auto_fit(true);
|
||||
app.global::<MessageColors>().set_preference("dark".into());
|
||||
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
let opened = Arc::new(Mutex::new(Vec::<String>::new()));
|
||||
let recorded = opened.clone();
|
||||
register(
|
||||
&app,
|
||||
|| None,
|
||||
runtime.handle(),
|
||||
Arc::new(Exports::default()),
|
||||
std::env::temp_dir(),
|
||||
ExternalUrlOpener::new(move |url| {
|
||||
recorded.lock().unwrap().push(url.to_owned());
|
||||
Ok(())
|
||||
}),
|
||||
);
|
||||
|
||||
// The dialog selects Report issue when it opens.
|
||||
app.set_rendering_info_open(true);
|
||||
let key = slint::SharedString::from(slint::platform::Key::Return);
|
||||
app.window()
|
||||
.dispatch_event(WindowEvent::KeyPressed { text: key.clone() });
|
||||
app.window()
|
||||
.dispatch_event(WindowEvent::KeyReleased { text: key });
|
||||
// The reading pane copies to the clipboard from a change handler.
|
||||
slint::platform::update_timers_and_animations();
|
||||
let opened = opened.lock().unwrap().clone();
|
||||
let copied = clipboard.borrow().clone();
|
||||
(app, opened, copied)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_issue_copies_the_html_and_opens_the_prefilled_form() {
|
||||
let (app, opened, copied) = report_with_return(1280, false);
|
||||
let [url] = opened.as_slice() else {
|
||||
panic!("expected the issue form once, got {opened:?}");
|
||||
};
|
||||
let environment = url::Url::parse(url)
|
||||
.unwrap()
|
||||
.query_pairs()
|
||||
.find(|(key, _)| key == "environment")
|
||||
.unwrap()
|
||||
.1
|
||||
.into_owned();
|
||||
assert!(
|
||||
environment.starts_with(&format!("Flectar Mail {}", env!("CARGO_PKG_VERSION"))),
|
||||
"{environment}"
|
||||
);
|
||||
assert!(
|
||||
environment.ends_with(
|
||||
"\nGPU renderer, 85% zoom (fit to width), dark message colors, remote images blocked"
|
||||
),
|
||||
"{environment}"
|
||||
);
|
||||
assert_eq!(copied, SOURCE);
|
||||
assert_eq!(
|
||||
app.get_render_message().key,
|
||||
"Copied HTML source. Paste it into the issue."
|
||||
);
|
||||
}
|
||||
|
||||
/// Both layouts hold the dialog. The hidden copy must not take the focus
|
||||
/// from the one on screen.
|
||||
#[test]
|
||||
fn report_issue_answers_the_keyboard_in_the_single_pane_layout() {
|
||||
let (_app, opened, copied) = report_with_return(900, true);
|
||||
assert_eq!(opened.len(), 1, "{opened:?}");
|
||||
assert_eq!(copied, SOURCE);
|
||||
}
|
||||
}
|
||||
@@ -310,6 +310,9 @@ export global I18n {
|
||||
if (key == "The message you were writing was saved to Drafts.") { return @tr("The message you were writing was saved to Drafts."); }
|
||||
if (key == "Files from links are never attached for you. Attach them yourself if you trust them: {}") { return @tr("Files from links are never attached for you. Attach them yourself if you trust them: {}", argument); }
|
||||
if (key == "Copied HTML source") { return @tr("Copied HTML source"); }
|
||||
if (key == "Copied HTML source. Paste it into the issue.") { return @tr("Copied HTML source. Paste it into the issue."); }
|
||||
if (key == "Saved HTML source. Attach the file to the issue.") { return @tr("Saved HTML source. Attach the file to the issue."); }
|
||||
if (key == "Could not save HTML source: {}") { return @tr("Could not save HTML source: {}", argument); }
|
||||
if (key == "Copied selected text") { return @tr("Copied selected text"); }
|
||||
if (key == "Copied email text") { return @tr("Copied email text"); }
|
||||
if (key == "Copied link") { return @tr("Copied link"); }
|
||||
@@ -744,6 +747,7 @@ export component AppWindow inherits Window {
|
||||
callback cancel_storage_usage();
|
||||
callback open_email_link(string);
|
||||
callback copy_email_status(string);
|
||||
callback report_rendering_issue();
|
||||
callback email-pointer-event(float, float, string, bool, bool, bool, bool);
|
||||
callback email-scroll(float, float);
|
||||
callback email-key-event(string, bool, bool, bool, bool, bool, bool);
|
||||
@@ -1650,6 +1654,7 @@ export component AppWindow inherits Window {
|
||||
open_compose => { root.open_compose(); }
|
||||
open_email_link(url) => { root.open_email_link(url); }
|
||||
copy_email_status(status) => { root.copy_email_status(status); }
|
||||
report_rendering_issue => { root.report_rendering_issue(); }
|
||||
email-pointer-event(x, y, kind, control, shift, alt, meta) => {
|
||||
root.email-pointer-event(x, y, kind, control, shift, alt, meta);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,10 @@ import { TypeScale } from "../typography.slint";
|
||||
export component RenderingInfoDialog inherits Rectangle {
|
||||
in-out property <bool> open: false;
|
||||
in property <bool> compact: false;
|
||||
// Mail view keeps a reading pane, and so this dialog, for each layout.
|
||||
// Only the one on screen takes focus, since the window drops keys aimed
|
||||
// at a hidden element.
|
||||
in property <bool> shown: true;
|
||||
in property <color> accent;
|
||||
in property <color> accent-soft;
|
||||
in property <color> border;
|
||||
@@ -38,10 +42,18 @@ export component RenderingInfoDialog inherits Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
function take-focus() {
|
||||
if root.open && root.shown {
|
||||
dialog-focus.focus();
|
||||
}
|
||||
}
|
||||
|
||||
changed shown => { root.take-focus(); }
|
||||
|
||||
changed open => {
|
||||
if root.open {
|
||||
root.selected-action = 2;
|
||||
dialog-focus.focus();
|
||||
root.take-focus();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -285,6 +285,8 @@ export component MailReadingPane inherits FocusScope {
|
||||
enabled: false;
|
||||
in property <bool> compact: false;
|
||||
in property <bool> full-width: false;
|
||||
// Whether this pane is on screen. Mail view keeps one pane per layout.
|
||||
in property <bool> shown: true;
|
||||
private property <bool> attachment-menu-mode: root.height < 500px;
|
||||
in property <bool> show-avatars: true;
|
||||
// The title wrap width comes from the containing window, not this pane's
|
||||
@@ -532,6 +534,7 @@ export component MailReadingPane inherits FocusScope {
|
||||
callback open_message_in_browser();
|
||||
callback open_email_link(string);
|
||||
callback copy_email_status(string);
|
||||
callback report_rendering_issue();
|
||||
callback email-pointer-event(float, float, string, bool, bool, bool, bool);
|
||||
callback email-scroll(float, float);
|
||||
callback email-key-event(string, bool, bool, bool, bool, bool, bool);
|
||||
@@ -2073,6 +2076,7 @@ export component MailReadingPane inherits FocusScope {
|
||||
|
||||
RenderingInfoDialog {
|
||||
open <=> root.rendering_info_open;
|
||||
shown: root.shown;
|
||||
compact: root.compact;
|
||||
x: 0px;
|
||||
y: 0px;
|
||||
@@ -2091,8 +2095,6 @@ export component MailReadingPane inherits FocusScope {
|
||||
clipboard-source.copy();
|
||||
root.copy_email_status("Copied HTML source");
|
||||
}
|
||||
report-issue => {
|
||||
root.open_email_link("https://github.com/flectar/mail/issues/new?title=Email%20rendering%20issue&body=%23%23%20What%20looked%20wrong%3F%0A%0A%23%23%20Email%20HTML%20source%0A%3C!--%20Remove%20private%20information%20before%20pasting.%20--%3E%0A");
|
||||
}
|
||||
report-issue => { root.report_rendering_issue(); }
|
||||
}
|
||||
}
|
||||
@@ -172,6 +172,7 @@ export component MailView inherits FocusScope {
|
||||
callback open_compose();
|
||||
callback open_email_link(string);
|
||||
callback copy_email_status(string);
|
||||
callback report_rendering_issue();
|
||||
callback email-pointer-event(float, float, string, bool, bool, bool, bool);
|
||||
callback email-scroll(float, float);
|
||||
callback email-key-event(string, bool, bool, bool, bool, bool, bool);
|
||||
@@ -434,6 +435,7 @@ export component MailView inherits FocusScope {
|
||||
}
|
||||
|
||||
desktop-reading-pane := MailReadingPane {
|
||||
shown: !root.single-pane;
|
||||
show-avatars: root.show-avatars;
|
||||
// This is the flexible column. Explicit constraints prevent
|
||||
// the subject's wrapping width from becoming a hard maximum,
|
||||
@@ -507,6 +509,7 @@ export component MailView inherits FocusScope {
|
||||
open_message_in_browser => { root.open_message_in_browser(); }
|
||||
open_email_link(url) => { root.open_email_link(url); }
|
||||
copy_email_status(status) => { root.copy_email_status(status); }
|
||||
report_rendering_issue => { root.report_rendering_issue(); }
|
||||
load-remote-images-for-message => { root.load-remote-images-for-message(); }
|
||||
email-pointer-event(x, y, kind, control, shift, alt, meta) => {
|
||||
root.email-pointer-event(x, y, kind, control, shift, alt, meta);
|
||||
@@ -668,6 +671,7 @@ export component MailView inherits FocusScope {
|
||||
full-width: true;
|
||||
show-avatars: root.show-avatars;
|
||||
visible: root.compact-page == "message";
|
||||
shown: root.single-pane && self.visible;
|
||||
x: root.single-pane-content-x
|
||||
+ (root.compact-page == "message" ? 0px : root.page-transition-distance);
|
||||
y: 0px;
|
||||
@@ -743,6 +747,7 @@ export component MailView inherits FocusScope {
|
||||
open_message_in_browser => { root.open_message_in_browser(); }
|
||||
open_email_link(url) => { root.open_email_link(url); }
|
||||
copy_email_status(status) => { root.copy_email_status(status); }
|
||||
report_rendering_issue => { root.report_rendering_issue(); }
|
||||
load-remote-images-for-message => { root.load-remote-images-for-message(); }
|
||||
email-pointer-event(x, y, kind, control, shift, alt, meta) => {
|
||||
root.email-pointer-event(x, y, kind, control, shift, alt, meta);
|
||||
|
||||
Reference in new issue
Block a user