mirror of
https://github.com/priyanshujain/margin-mail.git
synced 2026-10-04 20:17:05 +00:00
some more fixes
This commit is contained in:
1 parent
7274a54a6e
commit
9fccd563f6
40 files changed
+1690
-104
No files matched your search
@@ -0,0 +1,18 @@
|
|||||||
|
## Project Guidelines
|
||||||
|
|
||||||
|
- After completing changes, build and install the updated app for manual testing.
|
||||||
|
- Do not call the task done until it is fully complete and tested.
|
||||||
|
- Do not dismiss bug as a pre-existing" issue even if it was present before your change. It does not matter, it's still your responsibility to fix it. When you see a bug, fix it. Don't ignore it.
|
||||||
|
|
||||||
|
## Coding Guidelines
|
||||||
|
|
||||||
|
- Keep code simple and easy to read.
|
||||||
|
- Avoid excessive comments. Only comment when absolutely necessary. Code should be readable and not require comments to understand it.
|
||||||
|
|
||||||
|
## Git Commit Rules
|
||||||
|
|
||||||
|
- Do not make branches, commit in main only
|
||||||
|
- Commit message is one plain lowercase line. No type prefix, no scope, no body.
|
||||||
|
- Never use `git add .` or `git add -A`. Always stage specific files by name.
|
||||||
|
- Don't batch multiple unrelated changes into one commit.
|
||||||
|
|
||||||
@@ -53,7 +53,14 @@ build:
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
case "$(uname -s)" in
|
case "$(uname -s)" in
|
||||||
Darwin) pnpm tauri build --bundles app ;;
|
Darwin)
|
||||||
|
autofill_config=$(node scripts/prepare-autofill.mjs)
|
||||||
|
if [ -n "$autofill_config" ]; then
|
||||||
|
pnpm tauri build --bundles app --config "$autofill_config"
|
||||||
|
else
|
||||||
|
pnpm tauri build --bundles app
|
||||||
|
fi
|
||||||
|
;;
|
||||||
Linux) pnpm tauri build --bundles deb,appimage ;;
|
Linux) pnpm tauri build --bundles deb,appimage ;;
|
||||||
*) echo "just: no local build for $(uname -s); macOS and Linux are the desktop targets." >&2; exit 1 ;;
|
*) echo "just: no local build for $(uname -s); macOS and Linux are the desktop targets." >&2; exit 1 ;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import AppKit
|
||||||
|
import AuthenticationServices
|
||||||
|
|
||||||
|
private struct EmailCode: Decodable {
|
||||||
|
let id: String
|
||||||
|
let code: String
|
||||||
|
let domain: String
|
||||||
|
let label: String
|
||||||
|
let expiresAtMs: Double
|
||||||
|
|
||||||
|
static func available() -> [EmailCode] {
|
||||||
|
guard let container = FileManager.default.containerURL(
|
||||||
|
forSecurityApplicationGroupIdentifier: "TQV87WLXK3.studio.margin.mail"
|
||||||
|
),
|
||||||
|
let data = try? Data(contentsOf: container.appendingPathComponent("email-otp.json")),
|
||||||
|
let codes = try? JSONDecoder().decode([EmailCode].self, from: data) else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
let now = Date().timeIntervalSince1970 * 1000
|
||||||
|
return codes.filter {
|
||||||
|
!$0.id.isEmpty && !$0.code.isEmpty && $0.code.count <= 32
|
||||||
|
&& !$0.domain.isEmpty && $0.expiresAtMs > now
|
||||||
|
&& $0.expiresAtMs <= now + 180_000
|
||||||
|
}.sorted { $0.expiresAtMs > $1.expiresAtMs }
|
||||||
|
}
|
||||||
|
|
||||||
|
func matches(_ service: ASCredentialServiceIdentifier) -> Bool {
|
||||||
|
let host: String
|
||||||
|
if service.type == .URL {
|
||||||
|
guard let urlHost = URL(string: service.identifier)?.host else { return false }
|
||||||
|
host = urlHost.lowercased()
|
||||||
|
} else if service.type == .domain {
|
||||||
|
host = service.identifier.lowercased()
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
let senderDomain = domain.lowercased()
|
||||||
|
return host == senderDomain || host.hasSuffix("." + senderDomain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(macOS 15.0, *)
|
||||||
|
final class CredentialProviderViewController: ASCredentialProviderViewController {
|
||||||
|
override func loadView() {
|
||||||
|
view = NSView(frame: NSRect(x: 0, y: 0, width: 420, height: 340))
|
||||||
|
}
|
||||||
|
|
||||||
|
override func provideCredentialWithoutUserInteraction(for credentialRequest: any ASCredentialRequest) {
|
||||||
|
provide(credentialRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func prepareInterfaceToProvideCredential(for credentialRequest: any ASCredentialRequest) {
|
||||||
|
provide(credentialRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func prepareOneTimeCodeCredentialList(for serviceIdentifiers: [ASCredentialServiceIdentifier]) {
|
||||||
|
let codes = EmailCode.available()
|
||||||
|
let matching = codes.filter { code in serviceIdentifiers.contains { code.matches($0) } }
|
||||||
|
let others = codes.filter { code in !serviceIdentifiers.contains { code.matches($0) } }
|
||||||
|
let stack = NSStackView()
|
||||||
|
stack.orientation = .vertical
|
||||||
|
stack.alignment = .leading
|
||||||
|
stack.spacing = 12
|
||||||
|
stack.edgeInsets = NSEdgeInsets(top: 20, left: 20, bottom: 20, right: 20)
|
||||||
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
||||||
|
let title = NSTextField(labelWithString: "Email verification codes")
|
||||||
|
title.font = .boldSystemFont(ofSize: 17)
|
||||||
|
stack.addArrangedSubview(title)
|
||||||
|
|
||||||
|
if codes.isEmpty {
|
||||||
|
let empty = NSTextField(wrappingLabelWithString:
|
||||||
|
"No recent codes are available. Enable Email OTP AutoFill in Margin Mail and keep the app unlocked."
|
||||||
|
)
|
||||||
|
stack.addArrangedSubview(empty)
|
||||||
|
} else {
|
||||||
|
add(matching, to: stack)
|
||||||
|
if !others.isEmpty {
|
||||||
|
if !serviceIdentifiers.isEmpty {
|
||||||
|
let label = NSTextField(wrappingLabelWithString:
|
||||||
|
"Other email codes. Select only the code for the website you are using."
|
||||||
|
)
|
||||||
|
label.textColor = .secondaryLabelColor
|
||||||
|
stack.addArrangedSubview(label)
|
||||||
|
}
|
||||||
|
add(others, to: stack)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancel = NSButton(title: "Cancel", target: self, action: #selector(cancelSelection))
|
||||||
|
cancel.bezelStyle = .rounded
|
||||||
|
stack.addArrangedSubview(cancel)
|
||||||
|
|
||||||
|
let scroll = NSScrollView()
|
||||||
|
scroll.hasVerticalScroller = true
|
||||||
|
scroll.drawsBackground = false
|
||||||
|
scroll.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
scroll.documentView = stack
|
||||||
|
view.subviews.forEach { $0.removeFromSuperview() }
|
||||||
|
view.addSubview(scroll)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
scroll.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
|
scroll.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
|
scroll.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
|
scroll.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||||
|
stack.leadingAnchor.constraint(equalTo: scroll.contentView.leadingAnchor),
|
||||||
|
stack.trailingAnchor.constraint(equalTo: scroll.contentView.trailingAnchor),
|
||||||
|
stack.topAnchor.constraint(equalTo: scroll.contentView.topAnchor),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
private func add(_ codes: [EmailCode], to stack: NSStackView) {
|
||||||
|
for code in codes {
|
||||||
|
let button = NSButton(
|
||||||
|
title: "\(code.code) · \(code.label)",
|
||||||
|
target: self,
|
||||||
|
action: #selector(selectCode(_:))
|
||||||
|
)
|
||||||
|
button.identifier = NSUserInterfaceItemIdentifier(code.id)
|
||||||
|
button.bezelStyle = .rounded
|
||||||
|
button.toolTip = code.domain
|
||||||
|
stack.addArrangedSubview(button)
|
||||||
|
let domain = NSTextField(labelWithString: code.domain)
|
||||||
|
domain.font = .systemFont(ofSize: 11)
|
||||||
|
domain.textColor = .secondaryLabelColor
|
||||||
|
stack.addArrangedSubview(domain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func provide(_ request: any ASCredentialRequest) {
|
||||||
|
guard request.type == .oneTimeCode,
|
||||||
|
let identity = request.credentialIdentity as? ASOneTimeCodeCredentialIdentity,
|
||||||
|
let id = identity.recordIdentifier,
|
||||||
|
let code = EmailCode.available().first(where: { $0.id == id }),
|
||||||
|
code.matches(identity.serviceIdentifier) else {
|
||||||
|
cancel(with: .credentialIdentityNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
extensionContext.completeOneTimeCodeRequest(using: ASOneTimeCodeCredential(code: code.code))
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func selectCode(_ sender: NSButton) {
|
||||||
|
guard let id = sender.identifier?.rawValue,
|
||||||
|
let code = EmailCode.available().first(where: { $0.id == id }) else {
|
||||||
|
cancel(with: .credentialIdentityNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
extensionContext.completeOneTimeCodeRequest(using: ASOneTimeCodeCredential(code: code.code))
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func cancelSelection() {
|
||||||
|
cancel(with: .userCanceled)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cancel(with code: ASExtensionError.Code) {
|
||||||
|
extensionContext.cancelRequest(withError: NSError(domain: ASExtensionErrorDomain, code: code.rawValue))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>en</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>Margin Mail</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>MarginMailAutoFill</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>studio.margin.mail.autofill</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>Margin Mail AutoFill</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>XPC!</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>0.1.0</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>LSMinimumSystemVersion</key>
|
||||||
|
<string>15.0</string>
|
||||||
|
<key>NSExtension</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExtensionPointIdentifier</key>
|
||||||
|
<string>com.apple.authentication-services-credential-provider-ui</string>
|
||||||
|
<key>NSExtensionPrincipalClass</key>
|
||||||
|
<string>MarginMailAutoFill.CredentialProviderViewController</string>
|
||||||
|
<key>NSExtensionAttributes</key>
|
||||||
|
<dict>
|
||||||
|
<key>ASCredentialProviderExtensionCapabilities</key>
|
||||||
|
<dict>
|
||||||
|
<key>ProvidesOneTimeCodes</key>
|
||||||
|
<true/>
|
||||||
|
<key>ProvidesPasswords</key>
|
||||||
|
<false/>
|
||||||
|
<key>ProvidesPasskeys</key>
|
||||||
|
<false/>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.app-sandbox</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.developer.authentication-services.autofill-credential-provider</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>TQV87WLXK3.studio.margin.mail</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.developer.authentication-services.autofill-credential-provider</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>TQV87WLXK3.studio.margin.mail</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -22,6 +22,8 @@
|
|||||||
"@tauri-apps/plugin-os": "^2",
|
"@tauri-apps/plugin-os": "^2",
|
||||||
"@tauri-apps/plugin-process": "^2",
|
"@tauri-apps/plugin-process": "^2",
|
||||||
"@tauri-apps/plugin-updater": "^2",
|
"@tauri-apps/plugin-updater": "^2",
|
||||||
|
"@tiptap/core": "^3.31.2",
|
||||||
|
"@tiptap/pm": "^3.31.2",
|
||||||
"@tiptap/react": "^3.31.2",
|
"@tiptap/react": "^3.31.2",
|
||||||
"@tiptap/starter-kit": "^3.31.2",
|
"@tiptap/starter-kit": "^3.31.2",
|
||||||
"margin-shared": "file:../../python/margin/shared",
|
"margin-shared": "file:../../python/margin/shared",
|
||||||
|
|||||||
Generated
+6
@@ -26,6 +26,12 @@ importers:
|
|||||||
'@tauri-apps/plugin-updater':
|
'@tauri-apps/plugin-updater':
|
||||||
specifier: ^2
|
specifier: ^2
|
||||||
version: 2.11.0
|
version: 2.11.0
|
||||||
|
'@tiptap/core':
|
||||||
|
specifier: ^3.31.2
|
||||||
|
version: 3.31.2(@tiptap/[email protected])
|
||||||
|
'@tiptap/pm':
|
||||||
|
specifier: ^3.31.2
|
||||||
|
version: 3.31.2
|
||||||
'@tiptap/react':
|
'@tiptap/react':
|
||||||
specifier: ^3.31.2
|
specifier: ^3.31.2
|
||||||
version: 3.31.2(@floating-ui/[email protected])(@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
version: 3.31.2(@floating-ui/[email protected])(@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { access, copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { join, resolve } from "node:path";
|
||||||
|
|
||||||
|
const signing = process.env.MARGIN_SIGNING_DIR || join(homedir(), ".margin-signing");
|
||||||
|
const appProfile = join(signing, "studio.margin.mail.provisionprofile");
|
||||||
|
const extensionProfile = join(signing, "studio.margin.mail.autofill.provisionprofile");
|
||||||
|
const present = await Promise.all([appProfile, extensionProfile].map(async (path) => {
|
||||||
|
try { await access(path); return true; } catch (error) {
|
||||||
|
if (error.code === "ENOENT") return false;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
if (!present.every(Boolean)) {
|
||||||
|
console.error("Email OTP AutoFill is unavailable: both Margin Mail provisioning profiles are required.");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
const identity = process.env.APPLE_SIGNING_IDENTITY;
|
||||||
|
if (!identity || identity === "-") throw new Error("Email OTP AutoFill requires a Developer ID signing identity.");
|
||||||
|
const plist = (path) => JSON.parse(execFileSync("plutil", ["-convert", "json", "-o", "-", path], { encoding: "utf8" }));
|
||||||
|
const profile = (path, identifier) => {
|
||||||
|
const xml = execFileSync("security", ["cms", "-D", "-i", path]);
|
||||||
|
const entitlements = JSON.parse(execFileSync("plutil", ["-extract", "Entitlements", "json", "-o", "-", "-"], { input: xml, encoding: "utf8" }));
|
||||||
|
const expiry = execFileSync("plutil", ["-extract", "ExpirationDate", "raw", "-o", "-", "-"], { input: xml, encoding: "utf8" }).trim();
|
||||||
|
if (entitlements["com.apple.application-identifier"] !== `TQV87WLXK3.${identifier}` ||
|
||||||
|
entitlements["com.apple.developer.team-identifier"] !== "TQV87WLXK3" ||
|
||||||
|
entitlements["com.apple.developer.authentication-services.autofill-credential-provider"] !== true ||
|
||||||
|
!entitlements["com.apple.security.application-groups"]?.some((group) =>
|
||||||
|
group === "TQV87WLXK3.*" || group === "TQV87WLXK3.studio.margin.mail") ||
|
||||||
|
!(new Date(expiry).getTime() > Date.now())) {
|
||||||
|
throw new Error(`Invalid or expired AutoFill profile for ${identifier}.`);
|
||||||
|
}
|
||||||
|
return entitlements;
|
||||||
|
};
|
||||||
|
const appEntitlements = profile(appProfile, "studio.margin.mail");
|
||||||
|
const extensionEntitlements = profile(extensionProfile, "studio.margin.mail.autofill");
|
||||||
|
const metadata = JSON.parse(execFileSync("cargo", ["metadata", "--no-deps", "--format-version", "1", "--manifest-path", "src-tauri/Cargo.toml"], { encoding: "utf8" }));
|
||||||
|
const output = join(metadata.target_directory, ".tauri", "autofill");
|
||||||
|
const contents = join(output, "MarginMailAutoFill.appex", "Contents");
|
||||||
|
await mkdir(join(contents, "MacOS"), { recursive: true });
|
||||||
|
const config = JSON.parse(await readFile("src-tauri/tauri.conf.json", "utf8"));
|
||||||
|
const info = plist("native/autofill/Info.plist");
|
||||||
|
info.CFBundleShortVersionString = config.version;
|
||||||
|
info.CFBundleVersion = config.bundle.macOS.bundleVersion || config.version;
|
||||||
|
const writePlist = async (path, value) => {
|
||||||
|
await writeFile(path, JSON.stringify(value));
|
||||||
|
execFileSync("plutil", ["-convert", "xml1", path]);
|
||||||
|
};
|
||||||
|
await writePlist(join(contents, "Info.plist"), info);
|
||||||
|
await copyFile(extensionProfile, join(contents, "embedded.provisionprofile"));
|
||||||
|
for (const [name, authorized] of [["host", appEntitlements], ["extension", extensionEntitlements]]) {
|
||||||
|
const value = plist(name === "host" ? "native/autofill/host.entitlements" : "native/autofill/autofill.entitlements");
|
||||||
|
value["com.apple.application-identifier"] = authorized["com.apple.application-identifier"];
|
||||||
|
value["com.apple.developer.team-identifier"] = authorized["com.apple.developer.team-identifier"];
|
||||||
|
await writePlist(join(output, `${name}.entitlements`), value);
|
||||||
|
}
|
||||||
|
const arch = process.arch === "arm64" ? "arm64" : "x86_64";
|
||||||
|
execFileSync("xcrun", ["swiftc", "-O", "-parse-as-library", "-application-extension", "-module-name", "MarginMailAutoFill",
|
||||||
|
"-target", `${arch}-apple-macosx15.0`, "-Xlinker", "-e", "-Xlinker", "_NSExtensionMain",
|
||||||
|
"native/autofill/CredentialProvider.swift", "-o", join(contents, "MacOS", "MarginMailAutoFill")], { stdio: ["ignore", "ignore", "inherit"] });
|
||||||
|
const extension = join(output, "MarginMailAutoFill.appex");
|
||||||
|
execFileSync("codesign", ["--force", "--sign", identity, "--options", "runtime", "--timestamp", "--entitlements", join(output, "extension.entitlements"), extension], { stdio: ["ignore", "ignore", "inherit"] });
|
||||||
|
execFileSync("codesign", ["--verify", "--strict", extension], { stdio: ["ignore", "ignore", "inherit"] });
|
||||||
|
const overlay = join(output, "tauri.conf.json");
|
||||||
|
await writeFile(overlay, JSON.stringify({ bundle: { macOS: {
|
||||||
|
entitlements: join(output, "host.entitlements"),
|
||||||
|
files: { "PlugIns/MarginMailAutoFill.appex": extension, "embedded.provisionprofile": resolve(appProfile) },
|
||||||
|
} } }));
|
||||||
|
console.log(overlay);
|
||||||
Generated
+14
@@ -2946,10 +2946,13 @@ dependencies = [
|
|||||||
"mail-parser",
|
"mail-parser",
|
||||||
"mail-send",
|
"mail-send",
|
||||||
"objc2",
|
"objc2",
|
||||||
|
"objc2-app-kit",
|
||||||
"objc2-foundation",
|
"objc2-foundation",
|
||||||
|
"objc2-local-authentication",
|
||||||
"objc2-ui-kit",
|
"objc2-ui-kit",
|
||||||
"objc2-user-notifications",
|
"objc2-user-notifications",
|
||||||
"rand 0.8.8",
|
"rand 0.8.8",
|
||||||
|
"regex",
|
||||||
"reqwest 0.13.1",
|
"reqwest 0.13.1",
|
||||||
"roxmltree 0.21.1",
|
"roxmltree 0.21.1",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
@@ -3381,6 +3384,17 @@ dependencies = [
|
|||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-local-authentication"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e48e0b8b339e0d9d2ed4416b7f93f9d4daadff7d4dd797f89867cde11aeac607"
|
||||||
|
dependencies = [
|
||||||
|
"block2",
|
||||||
|
"objc2",
|
||||||
|
"objc2-foundation",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-osa-kit"
|
name = "objc2-osa-kit"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ base64 = "0.22"
|
|||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
url = "2"
|
url = "2"
|
||||||
|
regex = "1"
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
tokio = { version = "1", features = ["sync", "time", "net", "io-util", "rt"] }
|
tokio = { version = "1", features = ["sync", "time", "net", "io-util", "rt"] }
|
||||||
|
|
||||||
@@ -109,14 +110,26 @@ roxmltree = "0.21"
|
|||||||
# build keeps a single copy of objc2.
|
# build keeps a single copy of objc2.
|
||||||
[target.'cfg(target_os = "macos")'.dependencies]
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
objc2 = "0.6"
|
objc2 = "0.6"
|
||||||
|
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSSpellChecker", "NSApplication", "NSMenu", "NSMenuItem"] }
|
||||||
objc2-foundation = { version = "0.3", default-features = false, features = [
|
objc2-foundation = { version = "0.3", default-features = false, features = [
|
||||||
"std",
|
"std",
|
||||||
|
"NSArray",
|
||||||
|
"NSRange",
|
||||||
|
"NSFileManager",
|
||||||
|
"NSURL",
|
||||||
|
"NSTextCheckingResult",
|
||||||
|
"NSValue",
|
||||||
"NSDictionary",
|
"NSDictionary",
|
||||||
"NSError",
|
"NSError",
|
||||||
|
|
||||||
"NSObject",
|
"NSObject",
|
||||||
"NSString",
|
"NSString",
|
||||||
] }
|
] }
|
||||||
|
objc2-local-authentication = { version = "0.3", default-features = false, features = [
|
||||||
|
"std",
|
||||||
|
"block2",
|
||||||
|
"LAContext",
|
||||||
|
] }
|
||||||
objc2-user-notifications = { version = "0.3", default-features = false, features = [
|
objc2-user-notifications = { version = "0.3", default-features = false, features = [
|
||||||
"std",
|
"std",
|
||||||
"block2",
|
"block2",
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
use tauri::Manager;
|
||||||
|
|
||||||
|
pub struct AppLock {
|
||||||
|
enabled: AtomicBool,
|
||||||
|
unlocked: AtomicBool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppLock {
|
||||||
|
pub fn new(enabled: bool) -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: AtomicBool::new(enabled),
|
||||||
|
unlocked: AtomicBool::new(!enabled),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn configure(&self, enabled: bool) {
|
||||||
|
self.enabled.store(enabled, Ordering::Release);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_locked(&self) -> bool {
|
||||||
|
self.enabled.load(Ordering::Acquire) && !self.unlocked.load(Ordering::Acquire)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct LockStatus {
|
||||||
|
enabled: bool,
|
||||||
|
locked: bool,
|
||||||
|
available: bool,
|
||||||
|
unavailable_reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn permits<R: tauri::Runtime>(invoke: &tauri::ipc::Invoke<R>) -> bool {
|
||||||
|
matches!(invoke.message.command(), "app_lock_status" | "app_unlock")
|
||||||
|
|| !invoke.message.state_ref().get::<AppLock>().is_locked()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_locked(app: &tauri::AppHandle) -> bool {
|
||||||
|
app.try_state::<AppLock>()
|
||||||
|
.map(|lock| lock.is_locked())
|
||||||
|
.unwrap_or(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_setting_change(before: bool, after: bool) -> Result<(), String> {
|
||||||
|
if before != after {
|
||||||
|
authenticate(if after {
|
||||||
|
"Authenticate to require unlocking your mailbox when opening the app."
|
||||||
|
} else {
|
||||||
|
"Authenticate to turn off the mailbox opening lock."
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn app_lock_status(app: tauri::AppHandle) -> LockStatus {
|
||||||
|
let lock = app.state::<AppLock>();
|
||||||
|
let unavailable_reason = availability().err();
|
||||||
|
LockStatus {
|
||||||
|
enabled: lock.enabled.load(Ordering::Acquire),
|
||||||
|
locked: lock.is_locked(),
|
||||||
|
available: unavailable_reason.is_none(),
|
||||||
|
unavailable_reason,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn app_unlock(app: tauri::AppHandle) -> Result<(), String> {
|
||||||
|
let lock = app.state::<AppLock>();
|
||||||
|
if lock.is_locked() {
|
||||||
|
authenticate("Unlock your mailbox to read and write email.")?;
|
||||||
|
lock.unlocked.store(true, Ordering::Release);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn availability() -> Result<(), String> {
|
||||||
|
use objc2_local_authentication::{LAContext, LAPolicy};
|
||||||
|
|
||||||
|
let context = unsafe { LAContext::new() };
|
||||||
|
unsafe { context.canEvaluatePolicy_error(LAPolicy::DeviceOwnerAuthentication) }
|
||||||
|
.map_err(|error| error.localizedDescription().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn authenticate(reason: &str) -> Result<(), String> {
|
||||||
|
use std::sync::mpsc;
|
||||||
|
|
||||||
|
use block2::RcBlock;
|
||||||
|
use objc2::runtime::Bool;
|
||||||
|
use objc2_foundation::{NSError, NSString};
|
||||||
|
use objc2_local_authentication::{LAContext, LAPolicy};
|
||||||
|
|
||||||
|
let context = unsafe { LAContext::new() };
|
||||||
|
unsafe { context.canEvaluatePolicy_error(LAPolicy::DeviceOwnerAuthentication) }
|
||||||
|
.map_err(|error| error.localizedDescription().to_string())?;
|
||||||
|
let (sender, receiver) = mpsc::channel();
|
||||||
|
let reply = RcBlock::new(move |success: Bool, error: *mut NSError| {
|
||||||
|
let result = if success.as_bool() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
let message = if error.is_null() {
|
||||||
|
"Authentication was not completed.".to_string()
|
||||||
|
} else {
|
||||||
|
unsafe { &*error }.localizedDescription().to_string()
|
||||||
|
};
|
||||||
|
Err(message)
|
||||||
|
};
|
||||||
|
let _ = sender.send(result);
|
||||||
|
});
|
||||||
|
unsafe {
|
||||||
|
context.evaluatePolicy_localizedReason_reply(
|
||||||
|
LAPolicy::DeviceOwnerAuthentication,
|
||||||
|
&NSString::from_str(reason),
|
||||||
|
&reply,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
receiver
|
||||||
|
.recv()
|
||||||
|
.map_err(|_| "macOS did not complete authentication.".to_string())?
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
fn availability() -> Result<(), String> {
|
||||||
|
Err("The app opening lock is currently available on macOS.".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
fn authenticate(_reason: &str) -> Result<(), String> {
|
||||||
|
availability()
|
||||||
|
}
|
||||||
@@ -40,6 +40,35 @@ pub const MAX_ENCODED_BYTES: u64 = 35 * 1024 * 1024;
|
|||||||
/// arrives; this decides how often one of them leaves the machine.
|
/// arrives; this decides how often one of them leaves the machine.
|
||||||
pub const UPLOAD_EVERY_MS: i64 = 5_000;
|
pub const UPLOAD_EVERY_MS: i64 = 5_000;
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn draft_attachment_store(
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
account_id: String,
|
||||||
|
filename: String,
|
||||||
|
mime_type: String,
|
||||||
|
data_base64: String,
|
||||||
|
) -> Result<DraftAttachment, String> {
|
||||||
|
use base64::Engine;
|
||||||
|
if data_base64.len() as u64 > MAX_ENCODED_BYTES {
|
||||||
|
return Err(format!("{filename} is too large to attach"));
|
||||||
|
}
|
||||||
|
let bytes = base64::engine::general_purpose::STANDARD
|
||||||
|
.decode(data_base64).map_err(|e| e.to_string())?;
|
||||||
|
let db = db_of(&app)?;
|
||||||
|
db.with(&account_id, |_| Ok(()))?;
|
||||||
|
let directory = db.account_dir(&account_id).join("draft-files");
|
||||||
|
std::fs::create_dir_all(&directory).map_err(|e| e.to_string())?;
|
||||||
|
let path = directory.join(write::fresh_id("file"));
|
||||||
|
std::fs::write(&path, &bytes).map_err(|e| format!("{filename} could not be stored: {e}"))?;
|
||||||
|
Ok(DraftAttachment {
|
||||||
|
path: Some(path.to_string_lossy().into_owned()),
|
||||||
|
attachment_id: None,
|
||||||
|
filename,
|
||||||
|
mime_type,
|
||||||
|
size: bytes.len() as u64,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// What sits in the `drafts` row's payload.
|
/// What sits in the `drafts` row's payload.
|
||||||
///
|
///
|
||||||
/// The version is bumped on every save and is what says whether the provider is behind. A
|
/// The version is bumped on every save and is what says whether the provider is behind. A
|
||||||
|
|||||||
@@ -785,6 +785,16 @@ pub struct Settings {
|
|||||||
/// because they are a decision about a person and they roam with the rest of those.
|
/// because they are a decision about a person and they roam with the rest of those.
|
||||||
pub remote_images: String,
|
pub remote_images: String,
|
||||||
pub link_cleaning: bool,
|
pub link_cleaning: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub otp_autofill_enabled: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub app_lock_enabled: bool,
|
||||||
|
#[serde(default = "on")]
|
||||||
|
pub spelling_enabled: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub grammar_enabled: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub writing_tools_enabled: bool,
|
||||||
|
|
||||||
pub screener_enabled: bool,
|
pub screener_enabled: bool,
|
||||||
/// A reply to a thread you are in is never held. Turning this off holds it anyway.
|
/// A reply to a thread you are in is never held. Turning this off holds it anyway.
|
||||||
|
|||||||
+25
-2
@@ -2,6 +2,7 @@
|
|||||||
// front of it, the modules are the parts, and a contract type that nothing has consumed yet is a
|
// front of it, the modules are the parts, and a contract type that nothing has consumed yet is a
|
||||||
// contract type rather than dead code.
|
// contract type rather than dead code.
|
||||||
pub mod accounts;
|
pub mod accounts;
|
||||||
|
pub mod app_lock;
|
||||||
pub mod attachments;
|
pub mod attachments;
|
||||||
pub mod imap;
|
pub mod imap;
|
||||||
pub mod backup;
|
pub mod backup;
|
||||||
@@ -24,6 +25,8 @@ pub mod mirror;
|
|||||||
pub mod notify;
|
pub mod notify;
|
||||||
pub mod piles;
|
pub mod piles;
|
||||||
pub mod provider;
|
pub mod provider;
|
||||||
|
pub mod proofing;
|
||||||
|
pub mod otp_autofill;
|
||||||
pub mod routing;
|
pub mod routing;
|
||||||
pub mod sanitize;
|
pub mod sanitize;
|
||||||
pub mod screener;
|
pub mod screener;
|
||||||
@@ -34,6 +37,7 @@ pub mod state;
|
|||||||
pub mod sync;
|
pub mod sync;
|
||||||
pub mod undo;
|
pub mod undo;
|
||||||
pub mod unsubscribe;
|
pub mod unsubscribe;
|
||||||
|
pub mod writingtools;
|
||||||
|
|
||||||
#[cfg(desktop)]
|
#[cfg(desktop)]
|
||||||
use tauri::menu::{Menu, MenuItemBuilder, MenuItemKind, PredefinedMenuItem, SubmenuBuilder};
|
use tauri::menu::{Menu, MenuItemBuilder, MenuItemKind, PredefinedMenuItem, SubmenuBuilder};
|
||||||
@@ -298,7 +302,9 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
builder = builder.manage(google::AuthState::default()).setup(|app| {
|
builder = builder.manage(google::AuthState::default()).setup(|app| {
|
||||||
|
app.manage(app_lock::AppLock::new(settings::load(app.handle())?.app_lock_enabled));
|
||||||
let handle = app.handle();
|
let handle = app.handle();
|
||||||
|
tauri::async_runtime::spawn_blocking(|| { let _ = otp_autofill::clear(); });
|
||||||
|
|
||||||
// The mirror and the state database, one connection per account, opened lazily by the
|
// The mirror and the state database, one connection per account, opened lazily by the
|
||||||
// first read. Managed here because every command that touches SQLite reaches for it.
|
// first read. Managed here because every command that touches SQLite reaches for it.
|
||||||
@@ -384,7 +390,15 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let app = builder
|
let app = builder
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler({
|
||||||
|
let handler: Box<dyn Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Send + Sync> = Box::new(tauri::generate_handler![
|
||||||
|
app_lock::app_lock_status,
|
||||||
|
app_lock::app_unlock,
|
||||||
|
proofing::proof_text,
|
||||||
|
otp_autofill::otp_autofill_status,
|
||||||
|
otp_autofill::otp_autofill_enable,
|
||||||
|
writingtools::writing_tools_available,
|
||||||
|
writingtools::run_writing_tool,
|
||||||
// Accounts and consent
|
// Accounts and consent
|
||||||
accounts::accounts_list,
|
accounts::accounts_list,
|
||||||
account_start,
|
account_start,
|
||||||
@@ -451,6 +465,7 @@ pub fn run() {
|
|||||||
contacts::contacts_suggest,
|
contacts::contacts_suggest,
|
||||||
// Writing
|
// Writing
|
||||||
drafts::draft_save,
|
drafts::draft_save,
|
||||||
|
drafts::draft_attachment_store,
|
||||||
drafts::draft_get,
|
drafts::draft_get,
|
||||||
drafts::draft_list,
|
drafts::draft_list,
|
||||||
drafts::draft_import,
|
drafts::draft_import,
|
||||||
@@ -489,7 +504,15 @@ pub fn run() {
|
|||||||
exports::keymap_path,
|
exports::keymap_path,
|
||||||
exports::keymap_reset,
|
exports::keymap_reset,
|
||||||
packaged_by
|
packaged_by
|
||||||
])
|
]);
|
||||||
|
move |invoke| {
|
||||||
|
if !app_lock::permits(&invoke) {
|
||||||
|
invoke.resolver.reject("Unlock your mailbox first.");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
handler(invoke)
|
||||||
|
}
|
||||||
|
})
|
||||||
.build(context)
|
.build(context)
|
||||||
.expect("error while building Margin Mail");
|
.expect("error while building Margin Mail");
|
||||||
|
|
||||||
|
|||||||
@@ -450,6 +450,9 @@ pub fn announce(app: &tauri::AppHandle, account_id: &str, arrivals: &[Arrival])
|
|||||||
|
|
||||||
/// Posts one notification, and writes down why when it could not.
|
/// Posts one notification, and writes down why when it could not.
|
||||||
fn post(app: &tauri::AppHandle, text: &Text) -> Result<(), String> {
|
fn post(app: &tauri::AppHandle, text: &Text) -> Result<(), String> {
|
||||||
|
if crate::app_lock::is_locked(app) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
let result = platform::post(app, text);
|
let result = platform::post(app, text);
|
||||||
if let Err(e) = &result {
|
if let Err(e) = &result {
|
||||||
crate::log::note("notify", &format!("could not post \"{}\": {e}", text.body));
|
crate::log::note("notify", &format!("could not post \"{}\": {e}", text.body));
|
||||||
@@ -582,4 +585,3 @@ pub fn opened(app: &tauri::AppHandle, target: Option<NotifyTarget>) {
|
|||||||
pub fn notify_take() -> Option<NotifyTarget> {
|
pub fn notify_take() -> Option<NotifyTarget> {
|
||||||
OPENED.lock().ok().and_then(|mut slot| slot.take())
|
OPENED.lock().ok().and_then(|mut slot| slot.take())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct EmailCode {
|
||||||
|
id: String,
|
||||||
|
code: String,
|
||||||
|
domain: String,
|
||||||
|
label: String,
|
||||||
|
expires_at_ms: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct AutoFillStatus {
|
||||||
|
available: bool,
|
||||||
|
enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn otp_autofill_status() -> AutoFillStatus {
|
||||||
|
AutoFillStatus {
|
||||||
|
available: native::available(),
|
||||||
|
enabled: native::enabled().unwrap_or(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn otp_autofill_enable(app: tauri::AppHandle) -> Result<bool, String> {
|
||||||
|
if !native::available() {
|
||||||
|
return Err("Email OTP AutoFill requires macOS 15 or later.".into());
|
||||||
|
}
|
||||||
|
let (sender, receiver) = tokio::sync::oneshot::channel();
|
||||||
|
app.run_on_main_thread(move || native::enable(sender))
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
receiver.await.map_err(|error| error.to_string())?
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_enabled() -> Result<(), String> {
|
||||||
|
if native::enabled()? {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err("Enable Margin Mail in macOS AutoFill & Passwords first.".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear() -> Result<(), String> {
|
||||||
|
native::publish(&[], None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn code_in(subject: &str, body: &str) -> Option<String> {
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
static CUE: LazyLock<regex::Regex> = LazyLock::new(|| {
|
||||||
|
regex::Regex::new(
|
||||||
|
r"(?i)\b(verification|security|authentication|confirmation|login|sign[ -]?in|one[ -]?time|otp|passcode|your\s+code|enter\s+(the\s+)?code)\b"
|
||||||
|
).unwrap()
|
||||||
|
});
|
||||||
|
static TOKEN: LazyLock<regex::Regex> =
|
||||||
|
LazyLock::new(|| regex::Regex::new(r"\b[A-Z0-9]{4,8}\b").unwrap());
|
||||||
|
static DURATION: LazyLock<regex::Regex> =
|
||||||
|
LazyLock::new(|| regex::Regex::new(r"(?i)^\s*(seconds?|minutes?|hours?|days?)\b").unwrap());
|
||||||
|
let (body, _) = crate::sanitize::quoted::split_text(body);
|
||||||
|
let text = format!("{subject}\n{body}");
|
||||||
|
let mut codes = HashSet::new();
|
||||||
|
for token in TOKEN.find_iter(&text) {
|
||||||
|
let value = token.as_str();
|
||||||
|
if !value.bytes().any(|byte| byte.is_ascii_digit())
|
||||||
|
|| (!value.bytes().all(|byte| byte.is_ascii_digit()) && value.len() < 6)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let before: String = text[..token.start()]
|
||||||
|
.chars()
|
||||||
|
.rev()
|
||||||
|
.take(90)
|
||||||
|
.collect::<String>()
|
||||||
|
.chars()
|
||||||
|
.rev()
|
||||||
|
.collect();
|
||||||
|
let after: String = text[token.end()..].chars().take(60).collect();
|
||||||
|
if CUE.is_match(&format!("{before}{value}{after}")) && !DURATION.is_match(&after) {
|
||||||
|
codes.insert(value.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if codes.len() == 1 {
|
||||||
|
codes.into_iter().next()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn refresh(app: &tauri::AppHandle) -> Result<(), String> {
|
||||||
|
use tauri::Manager;
|
||||||
|
if !crate::settings::load(app)?.otp_autofill_enabled || crate::app_lock::is_locked(app) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let db = app.state::<crate::db::Db>();
|
||||||
|
let now = crate::mirror::write::now_ms();
|
||||||
|
let mut codes = Vec::new();
|
||||||
|
for account_id in crate::sync::attached() {
|
||||||
|
let rows = db.with(&account_id, |conn| {
|
||||||
|
let mut query = conn
|
||||||
|
.prepare(
|
||||||
|
"SELECT m.id, m.subject, m.from_address, m.from_name, m.date_ms, b.text
|
||||||
|
FROM messages m LEFT JOIN bodies b ON b.message_id = m.id
|
||||||
|
JOIN threads t ON t.provider_thread_id = m.provider_thread_id
|
||||||
|
WHERE m.date_ms > ?1 AND m.date_ms <= ?2 AND m.seen = 0 AND m.sent = 0
|
||||||
|
AND m.draft = 0 AND m.hydrated = 1 AND t.spam = 0 AND t.trashed = 0
|
||||||
|
ORDER BY m.date_ms DESC LIMIT 20",
|
||||||
|
)
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
let rows = query
|
||||||
|
.query_map(rusqlite::params![now - 180_000, now], |row| {
|
||||||
|
Ok((
|
||||||
|
row.get::<_, String>(0)?,
|
||||||
|
row.get::<_, String>(1)?,
|
||||||
|
row.get::<_, String>(2)?,
|
||||||
|
row.get::<_, Option<String>>(3)?,
|
||||||
|
row.get::<_, i64>(4)?,
|
||||||
|
row.get::<_, Option<String>>(5)?,
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
rows.collect::<Result<Vec<_>, _>>()
|
||||||
|
.map_err(|error| error.to_string())
|
||||||
|
})?;
|
||||||
|
for (id, subject, sender, name, date, stored_body) in rows {
|
||||||
|
let body = if let Some(body) = stored_body {
|
||||||
|
body
|
||||||
|
} else {
|
||||||
|
let Some(remote) = crate::sync::remote_for(&account_id) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let raw = match remote.fetch_body(&id).await {
|
||||||
|
Ok(raw) => raw,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
db.with(&account_id, |conn| {
|
||||||
|
let options = crate::sync::hydrate::render_options(conn)?;
|
||||||
|
crate::mirror::write::store_body(conn, &id, &raw, &options)?;
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT text FROM bodies WHERE message_id = ?1",
|
||||||
|
[&id],
|
||||||
|
|row| row.get::<_, String>(0),
|
||||||
|
)
|
||||||
|
.map_err(|error| error.to_string())
|
||||||
|
})?
|
||||||
|
};
|
||||||
|
let Some(code) = code_in(&subject, &body) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some((_, domain)) = sender.rsplit_once('@') else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let domain = domain.to_lowercase();
|
||||||
|
if !domain.contains('.')
|
||||||
|
|| domain.chars().any(|character| {
|
||||||
|
!(character.is_ascii_alphanumeric() || character == '.' || character == '-')
|
||||||
|
})
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
codes.push(EmailCode {
|
||||||
|
id: format!("{account_id}:{id}"),
|
||||||
|
code,
|
||||||
|
domain,
|
||||||
|
label: name
|
||||||
|
.filter(|name| !name.trim().is_empty())
|
||||||
|
.unwrap_or(sender),
|
||||||
|
expires_at_ms: date + 180_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !crate::settings::load(app)?.otp_autofill_enabled || crate::app_lock::is_locked(app) {
|
||||||
|
return clear();
|
||||||
|
}
|
||||||
|
codes.retain(|code| code.expires_at_ms > crate::mirror::write::now_ms());
|
||||||
|
native::publish(&codes, Some(app))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
mod native {
|
||||||
|
use super::EmailCode;
|
||||||
|
use block2::RcBlock;
|
||||||
|
use objc2::{
|
||||||
|
msg_send,
|
||||||
|
rc::{Allocated, Retained},
|
||||||
|
runtime::{AnyClass, Bool},
|
||||||
|
};
|
||||||
|
use objc2_foundation::{NSArray, NSError, NSFileManager, NSObject, NSString};
|
||||||
|
use std::sync::{mpsc, Mutex};
|
||||||
|
|
||||||
|
#[link(name = "AuthenticationServices", kind = "framework")]
|
||||||
|
unsafe extern "C" {}
|
||||||
|
static PUBLISH: Mutex<()> = Mutex::new(());
|
||||||
|
|
||||||
|
pub fn available() -> bool {
|
||||||
|
let bundled = std::env::current_exe()
|
||||||
|
.ok()
|
||||||
|
.and_then(|path| {
|
||||||
|
path.parent()?.parent().map(|path| {
|
||||||
|
path.join("PlugIns/MarginMailAutoFill.appex/Contents/embedded.provisionprofile")
|
||||||
|
.is_file()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap_or(false);
|
||||||
|
bundled
|
||||||
|
&& AnyClass::get(c"ASOneTimeCodeCredentialIdentity").is_some()
|
||||||
|
&& NSFileManager::defaultManager()
|
||||||
|
.containerURLForSecurityApplicationGroupIdentifier(&NSString::from_str(
|
||||||
|
"TQV87WLXK3.studio.margin.mail",
|
||||||
|
))
|
||||||
|
.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn store() -> Result<Retained<NSObject>, String> {
|
||||||
|
let class =
|
||||||
|
AnyClass::get(c"ASCredentialIdentityStore").ok_or("macOS AutoFill is unavailable")?;
|
||||||
|
Ok(unsafe { msg_send![class, sharedStore] })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn enabled() -> Result<bool, String> {
|
||||||
|
if !available() {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
let (sender, receiver) = mpsc::channel();
|
||||||
|
let reply = RcBlock::new(move |state: *mut NSObject| {
|
||||||
|
let enabled: Bool = unsafe { msg_send![state, isEnabled] };
|
||||||
|
let _ = sender.send(enabled.as_bool());
|
||||||
|
});
|
||||||
|
unsafe {
|
||||||
|
let _: () =
|
||||||
|
msg_send![&*store()?, getCredentialIdentityStoreStateWithCompletion: &*reply];
|
||||||
|
}
|
||||||
|
receiver.recv().map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn enable(sender: tokio::sync::oneshot::Sender<Result<bool, String>>) {
|
||||||
|
let Some(class) = AnyClass::get(c"ASSettingsHelper") else {
|
||||||
|
let _ = sender.send(Err("macOS AutoFill is unavailable".into()));
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let sender = std::sync::Mutex::new(Some(sender));
|
||||||
|
let reply = RcBlock::new(move |enabled: Bool| {
|
||||||
|
if let Some(sender) = sender.lock().ok().and_then(|mut sender| sender.take()) {
|
||||||
|
let _ = sender.send(Ok(enabled.as_bool()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
unsafe {
|
||||||
|
let _: () = msg_send![class, requestToTurnOnCredentialProviderExtensionWithCompletionHandler: &*reply];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn publish(codes: &[EmailCode], app: Option<&tauri::AppHandle>) -> Result<(), String> {
|
||||||
|
if !available() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let _guard = PUBLISH.lock().map_err(|error| error.to_string())?;
|
||||||
|
let codes = if let Some(app) = app {
|
||||||
|
if !crate::settings::load(app)?.otp_autofill_enabled || crate::app_lock::is_locked(app)
|
||||||
|
{
|
||||||
|
&[]
|
||||||
|
} else {
|
||||||
|
codes
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
codes
|
||||||
|
};
|
||||||
|
let directory = NSFileManager::defaultManager()
|
||||||
|
.containerURLForSecurityApplicationGroupIdentifier(&NSString::from_str(
|
||||||
|
"TQV87WLXK3.studio.margin.mail",
|
||||||
|
))
|
||||||
|
.and_then(|url| url.path())
|
||||||
|
.ok_or("Margin Mail AutoFill signing is not configured")?;
|
||||||
|
let path = std::path::PathBuf::from(directory.to_string());
|
||||||
|
std::fs::create_dir_all(&path).map_err(|error| error.to_string())?;
|
||||||
|
let temporary = path.join("email-otp.pending");
|
||||||
|
use std::io::Write;
|
||||||
|
use std::os::unix::fs::OpenOptionsExt;
|
||||||
|
let mut file = std::fs::OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create(true)
|
||||||
|
.truncate(true)
|
||||||
|
.mode(0o600)
|
||||||
|
.open(&temporary)
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
file.write_all(&serde_json::to_vec(codes).map_err(|error| error.to_string())?)
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
std::fs::rename(&temporary, path.join("email-otp.json"))
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
if !enabled()? {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let service_class = AnyClass::get(c"ASCredentialServiceIdentifier")
|
||||||
|
.ok_or("macOS AutoFill is unavailable")?;
|
||||||
|
let identity_class = AnyClass::get(c"ASOneTimeCodeCredentialIdentity")
|
||||||
|
.ok_or("Email OTP AutoFill requires macOS 15")?;
|
||||||
|
let mut identities: Vec<Retained<NSObject>> = Vec::new();
|
||||||
|
for code in codes {
|
||||||
|
let allocated: Allocated<NSObject> = unsafe { msg_send![service_class, alloc] };
|
||||||
|
let service: Retained<NSObject> = unsafe {
|
||||||
|
msg_send![allocated, initWithIdentifier: &*NSString::from_str(&code.domain), type: 0isize]
|
||||||
|
};
|
||||||
|
let allocated: Allocated<NSObject> = unsafe { msg_send![identity_class, alloc] };
|
||||||
|
let identity = unsafe {
|
||||||
|
msg_send![allocated, initWithServiceIdentifier: &*service, label: &*NSString::from_str(&code.label), recordIdentifier: &*NSString::from_str(&code.id)]
|
||||||
|
};
|
||||||
|
identities.push(identity);
|
||||||
|
}
|
||||||
|
let entries = NSArray::from_retained_slice(&identities);
|
||||||
|
let (sender, receiver) = mpsc::channel();
|
||||||
|
let reply = RcBlock::new(move |success: Bool, error: *mut NSError| {
|
||||||
|
let result = if success.as_bool() {
|
||||||
|
Ok(())
|
||||||
|
} else if error.is_null() {
|
||||||
|
Err("macOS could not update OTP suggestions".into())
|
||||||
|
} else {
|
||||||
|
Err(unsafe { &*error }.localizedDescription().to_string())
|
||||||
|
};
|
||||||
|
let _ = sender.send(result);
|
||||||
|
});
|
||||||
|
unsafe {
|
||||||
|
let _: () = msg_send![&*store()?, replaceCredentialIdentityEntries: &*entries, completion: &*reply];
|
||||||
|
}
|
||||||
|
receiver.recv().map_err(|error| error.to_string())?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
mod native {
|
||||||
|
use super::EmailCode;
|
||||||
|
pub fn available() -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
pub fn enabled() -> Result<bool, String> {
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
pub fn publish(_: &[EmailCode], _: Option<&tauri::AppHandle>) -> Result<(), String> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
pub fn enable(sender: tokio::sync::oneshot::Sender<Result<bool, String>>) {
|
||||||
|
let _ = sender.send(Err("Email OTP AutoFill requires macOS".into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ProofIssue {
|
||||||
|
start: usize,
|
||||||
|
end: usize,
|
||||||
|
kind: &'static str,
|
||||||
|
message: String,
|
||||||
|
suggestions: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn proof_text(text: String, spelling: bool, grammar: bool) -> Result<Vec<ProofIssue>, String> {
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
tauri::async_runtime::spawn_blocking(move || check(&text, spelling, grammar))
|
||||||
|
.await
|
||||||
|
.map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
{
|
||||||
|
let _ = (text, spelling, grammar);
|
||||||
|
Ok(Vec::new())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn check(text: &str, spelling: bool, grammar: bool) -> Vec<ProofIssue> {
|
||||||
|
use objc2::rc::autoreleasepool;
|
||||||
|
use objc2_app_kit::NSSpellChecker;
|
||||||
|
use objc2_foundation::{NSArray, NSRange, NSString, NSTextCheckingType, NSValue};
|
||||||
|
|
||||||
|
autoreleasepool(|_| {
|
||||||
|
let checker = NSSpellChecker::sharedSpellChecker();
|
||||||
|
let string = NSString::from_str(text);
|
||||||
|
let mut types = NSTextCheckingType::empty();
|
||||||
|
if spelling {
|
||||||
|
types |= NSTextCheckingType::Spelling;
|
||||||
|
}
|
||||||
|
if grammar {
|
||||||
|
types |= NSTextCheckingType::Grammar;
|
||||||
|
}
|
||||||
|
let results = unsafe {
|
||||||
|
checker.checkString_range_types_options_inSpellDocumentWithTag_orthography_wordCount(
|
||||||
|
&string,
|
||||||
|
NSRange { location: 0, length: string.length() },
|
||||||
|
types.bits(),
|
||||||
|
None,
|
||||||
|
0,
|
||||||
|
None,
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let mut issues = Vec::new();
|
||||||
|
for result in results.iter() {
|
||||||
|
let range = result.range();
|
||||||
|
if result.resultType() == NSTextCheckingType::Spelling {
|
||||||
|
let suggestions = checker
|
||||||
|
.guessesForWordRange_inString_language_inSpellDocumentWithTag(range, &string, None, 0)
|
||||||
|
.map(|guesses| guesses.iter().take(5).map(|guess| guess.to_string()).collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
issues.push(ProofIssue {
|
||||||
|
start: range.location,
|
||||||
|
end: range.location + range.length,
|
||||||
|
kind: "spelling",
|
||||||
|
message: "Check spelling".into(),
|
||||||
|
suggestions,
|
||||||
|
});
|
||||||
|
} else if result.resultType() == NSTextCheckingType::Grammar {
|
||||||
|
if let Some(details) = result.grammarDetails() {
|
||||||
|
for detail in details.iter() {
|
||||||
|
let relative = detail.objectForKey(&NSString::from_str("NSGrammarRange"))
|
||||||
|
.and_then(|value| value.downcast_ref::<NSValue>().map(|value| unsafe { value.rangeValue() }));
|
||||||
|
let start = range.location + relative.map_or(0, |value| value.location);
|
||||||
|
let length = relative.map_or(range.length, |value| value.length);
|
||||||
|
let message = detail.objectForKey(&NSString::from_str("NSGrammarUserDescription"))
|
||||||
|
.and_then(|value| value.downcast_ref::<NSString>().map(|value| value.to_string()))
|
||||||
|
.unwrap_or_else(|| "Check grammar".into());
|
||||||
|
let suggestions = detail.objectForKey(&NSString::from_str("NSGrammarCorrections"))
|
||||||
|
.and_then(|value| value.downcast_ref::<NSArray>().map(|values| values.iter().filter_map(|value| value.downcast_ref::<NSString>().map(|value| value.to_string())).take(5).collect()))
|
||||||
|
.unwrap_or_default();
|
||||||
|
issues.push(ProofIssue { start, end: start + length, kind: "grammar", message, suggestions });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
issues
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -48,6 +48,11 @@ pub fn defaults() -> Settings {
|
|||||||
|
|
||||||
remote_images: "ask".to_string(),
|
remote_images: "ask".to_string(),
|
||||||
link_cleaning: true,
|
link_cleaning: true,
|
||||||
|
otp_autofill_enabled: false,
|
||||||
|
app_lock_enabled: false,
|
||||||
|
spelling_enabled: true,
|
||||||
|
grammar_enabled: false,
|
||||||
|
writing_tools_enabled: false,
|
||||||
|
|
||||||
screener_enabled: true,
|
screener_enabled: true,
|
||||||
hold_replies: false,
|
hold_replies: false,
|
||||||
@@ -152,7 +157,15 @@ pub fn settings_set(app: tauri::AppHandle, patch: Value) -> Result<Settings, Str
|
|||||||
let path = path(&app)?;
|
let path = path(&app)?;
|
||||||
let before = load(&app)?;
|
let before = load(&app)?;
|
||||||
let after = merge(&before, patch)?;
|
let after = merge(&before, patch)?;
|
||||||
|
crate::app_lock::validate_setting_change(before.app_lock_enabled, after.app_lock_enabled)?;
|
||||||
|
if !before.otp_autofill_enabled && after.otp_autofill_enabled {
|
||||||
|
crate::otp_autofill::validate_enabled()?;
|
||||||
|
}
|
||||||
write(&path, &after)?;
|
write(&path, &after)?;
|
||||||
|
if before.otp_autofill_enabled && !after.otp_autofill_enabled {
|
||||||
|
crate::otp_autofill::clear()?;
|
||||||
|
}
|
||||||
|
app.state::<crate::app_lock::AppLock>().configure(after.app_lock_enabled);
|
||||||
|
|
||||||
for (account_id, days) in window_changes(&before, &after) {
|
for (account_id, days) in window_changes(&before, &after) {
|
||||||
crate::sync::window_set(&app, &account_id, days)?;
|
crate::sync::window_set(&app, &account_id, days)?;
|
||||||
|
|||||||
@@ -294,6 +294,17 @@ impl Engine {
|
|||||||
remote: &dyn Remote,
|
remote: &dyn Remote,
|
||||||
sink: &dyn Sink,
|
sink: &dyn Sink,
|
||||||
foreground: bool,
|
foreground: bool,
|
||||||
|
) -> SyncStatus {
|
||||||
|
self.run_scheduled_pass(store, remote, sink, foreground, true).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run_scheduled_pass<S: Store>(
|
||||||
|
&self,
|
||||||
|
store: &S,
|
||||||
|
remote: &dyn Remote,
|
||||||
|
sink: &dyn Sink,
|
||||||
|
foreground: bool,
|
||||||
|
maintenance: bool,
|
||||||
) -> SyncStatus {
|
) -> SyncStatus {
|
||||||
if self.running.swap(true, Ordering::AcqRel) {
|
if self.running.swap(true, Ordering::AcqRel) {
|
||||||
return self.status();
|
return self.status();
|
||||||
@@ -337,9 +348,11 @@ impl Engine {
|
|||||||
// the seed is waiting on and asking beforehand would answer "not yet" on the very pass
|
// the seed is waiting on and asking beforehand would answer "not yet" on the very pass
|
||||||
// that made it ready.
|
// that made it ready.
|
||||||
self.seed_when_ready(store, sink);
|
self.seed_when_ready(store, sink);
|
||||||
|
if maintenance {
|
||||||
self.housekeeping(store, remote, sink, &mut status, foreground)
|
self.housekeeping(store, remote, sink, &mut status, foreground)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
status.pending_writes = store.with(write::pending_writes).unwrap_or(0);
|
status.pending_writes = store.with(write::pending_writes).unwrap_or(0);
|
||||||
status.oldest_ms = store
|
status.oldest_ms = store
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ pub struct Outcome {
|
|||||||
|
|
||||||
pub const POLL_FOREGROUND_SECS: u64 = 12;
|
pub const POLL_FOREGROUND_SECS: u64 = 12;
|
||||||
pub const POLL_BACKGROUND_SECS: u64 = 60;
|
pub const POLL_BACKGROUND_SECS: u64 = 60;
|
||||||
|
const RECEIVE_POLL_SECS: u64 = 5;
|
||||||
/// A cold start should show fresh mail rather than wait out a poll interval.
|
/// A cold start should show fresh mail rather than wait out a poll interval.
|
||||||
const FIRST_PASS_SECS: u64 = 2;
|
const FIRST_PASS_SECS: u64 = 2;
|
||||||
|
|
||||||
@@ -373,15 +374,23 @@ fn db_of(app: &tauri::AppHandle) -> Result<tauri::State<'_, Db>, String> {
|
|||||||
/// Starts the poll loop. The integrator calls this from `setup` once the database is managed.
|
/// Starts the poll loop. The integrator calls this from `setup` once the database is managed.
|
||||||
pub fn setup(app: tauri::AppHandle) {
|
pub fn setup(app: tauri::AppHandle) {
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
let mut delay = Duration::from_secs(FIRST_PASS_SECS);
|
let start = tokio::time::Instant::now() + Duration::from_secs(FIRST_PASS_SECS);
|
||||||
|
let mut polls = tokio::time::interval_at(start, Duration::from_secs(RECEIVE_POLL_SECS));
|
||||||
|
polls.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||||
|
let mut maintained: Option<tokio::time::Instant> = None;
|
||||||
loop {
|
loop {
|
||||||
tokio::time::sleep(delay).await;
|
polls.tick().await;
|
||||||
tick(&app).await;
|
let foreground = focused(&app);
|
||||||
delay = Duration::from_secs(if focused(&app) {
|
let cadence = Duration::from_secs(if foreground {
|
||||||
POLL_FOREGROUND_SECS
|
POLL_FOREGROUND_SECS
|
||||||
} else {
|
} else {
|
||||||
POLL_BACKGROUND_SECS
|
POLL_BACKGROUND_SECS
|
||||||
});
|
});
|
||||||
|
let maintenance = maintained.map(|last| last.elapsed() >= cadence).unwrap_or(true);
|
||||||
|
if maintenance {
|
||||||
|
maintained = Some(tokio::time::Instant::now());
|
||||||
|
}
|
||||||
|
tick(&app, foreground, maintenance).await;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -392,27 +401,35 @@ fn focused(app: &tauri::AppHandle) -> bool {
|
|||||||
.any(|window| window.is_focused().unwrap_or(false))
|
.any(|window| window.is_focused().unwrap_or(false))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn tick(app: &tauri::AppHandle) {
|
async fn tick(app: &tauri::AppHandle, foreground: bool, maintenance: bool) {
|
||||||
let Ok(db) = db_of(app) else { return };
|
let Ok(db) = db_of(app) else { return };
|
||||||
let sink = AppSink { app: app.clone() };
|
let sink = AppSink { app: app.clone() };
|
||||||
for account_id in attached() {
|
let db = db.inner();
|
||||||
|
let sink = &sink;
|
||||||
|
let passes = attached().into_iter().map(|account_id| async move {
|
||||||
let Some(remote) = remote_for(&account_id) else {
|
let Some(remote) = remote_for(&account_id) else {
|
||||||
continue;
|
return;
|
||||||
};
|
};
|
||||||
let engine = engine_for(&account_id);
|
let engine = engine_for(&account_id);
|
||||||
let store = Scoped {
|
let store = Scoped {
|
||||||
db: db.inner(),
|
db,
|
||||||
account_id: &account_id,
|
account_id: &account_id,
|
||||||
};
|
};
|
||||||
engine
|
engine
|
||||||
.run_pass(&store, remote.as_ref(), &sink, focused(app))
|
.run_scheduled_pass(&store, remote.as_ref(), sink, foreground, maintenance)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
// A draft written just before a quit has a local row and no provider copy yet. The pass is
|
// A draft written just before a quit has a local row and no provider copy yet. The pass is
|
||||||
// where it catches up, so a draft roams the way Gmail drafts always have rather than
|
// where it catches up, so a draft roams the way Gmail drafts always have rather than
|
||||||
// waiting for the composer to be opened again.
|
// waiting for the composer to be opened again.
|
||||||
|
if maintenance {
|
||||||
let _ = crate::drafts::upload_pending(app, &account_id).await;
|
let _ = crate::drafts::upload_pending(app, &account_id).await;
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
futures::future::join_all(passes).await;
|
||||||
|
if let Err(error) = crate::otp_autofill::refresh(app).await {
|
||||||
|
crate::log::note("autofill", &error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
mod mac {
|
||||||
|
use objc2::rc::Retained;
|
||||||
|
use objc2::runtime::AnyClass;
|
||||||
|
use objc2::{msg_send, sel, MainThreadMarker};
|
||||||
|
use objc2_app_kit::{NSApplication, NSMenu};
|
||||||
|
|
||||||
|
pub fn available() -> bool {
|
||||||
|
let Some(class) = AnyClass::get(c"NSWritingToolsCoordinator") else { return false };
|
||||||
|
unsafe {
|
||||||
|
let responds: bool = msg_send![class, respondsToSelector: sel!(isWritingToolsAvailable)];
|
||||||
|
responds && msg_send![class, isWritingToolsAvailable]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn writing_menu(mtm: MainThreadMarker) -> Option<Retained<NSMenu>> {
|
||||||
|
let main = NSApplication::sharedApplication(mtm).mainMenu()?;
|
||||||
|
for item in main.itemArray().iter() {
|
||||||
|
let Some(edit) = item.submenu() else { continue };
|
||||||
|
if edit.title().to_string() != "Edit" && item.title().to_string() != "Edit" { continue; }
|
||||||
|
for child in edit.itemArray().iter() {
|
||||||
|
if child.title().to_string() == "Writing Tools" { return child.submenu(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn perform(tool: &str) -> Result<(), String> {
|
||||||
|
let mtm = MainThreadMarker::new().ok_or("Writing Tools must run on the main thread")?;
|
||||||
|
if !available() {
|
||||||
|
return Err("Apple Writing Tools are unavailable. Check Apple Intelligence in System Settings.".into());
|
||||||
|
}
|
||||||
|
let menu = writing_menu(mtm).ok_or("Apple Writing Tools are unavailable in the app’s Edit menu")?;
|
||||||
|
menu.update();
|
||||||
|
for (index, item) in menu.itemArray().iter().enumerate() {
|
||||||
|
if item.title().to_string() != tool { continue; }
|
||||||
|
if !item.isEnabled() {
|
||||||
|
return Err("Select text in your message before using Apple Writing Tools.".into());
|
||||||
|
}
|
||||||
|
let action = item.action().ok_or("The Writing Tools action is unavailable")?;
|
||||||
|
let application = NSApplication::sharedApplication(mtm);
|
||||||
|
if unsafe { application.targetForAction_to_from(action, item.target().as_deref(), Some(&item)) }.is_none() {
|
||||||
|
return Err("Apple Writing Tools cannot edit the selected text.".into());
|
||||||
|
}
|
||||||
|
menu.performActionForItemAtIndex(index as isize);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(format!("Apple {tool} is unavailable in the app’s Edit menu"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn writing_tools_available() -> bool {
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{ mac::available() }
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
{ false }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn run_writing_tool(app: tauri::AppHandle, tool: String) -> Result<(), String> {
|
||||||
|
if !matches!(tool.as_str(), "Proofread" | "Rewrite") {
|
||||||
|
return Err("Unknown Writing Tools action".into());
|
||||||
|
}
|
||||||
|
if !crate::settings::load(&app)?.writing_tools_enabled {
|
||||||
|
return Err("Enable Apple Writing Tools in Writing settings first".into());
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
let (sender, receiver) = tokio::sync::oneshot::channel();
|
||||||
|
app.run_on_main_thread(move || { let _ = sender.send(mac::perform(&tool)); })
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
receiver.await.map_err(|error| error.to_string())?
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
{ Err("Apple Writing Tools require macOS".into()) }
|
||||||
|
}
|
||||||
+2
-1
@@ -9,6 +9,7 @@ import { Header } from "./screens/Header";
|
|||||||
import { ListColumn } from "./screens/ListColumn";
|
import { ListColumn } from "./screens/ListColumn";
|
||||||
import { Clips } from "./screens/Clips";
|
import { Clips } from "./screens/Clips";
|
||||||
import { Compose } from "./screens/Compose";
|
import { Compose } from "./screens/Compose";
|
||||||
|
import { AppLockGate } from "./screens/AppLock";
|
||||||
import { ContactCards } from "./screens/ContactCards";
|
import { ContactCards } from "./screens/ContactCards";
|
||||||
import { Contacts } from "./screens/Contacts";
|
import { Contacts } from "./screens/Contacts";
|
||||||
import { Feed } from "./screens/Feed";
|
import { Feed } from "./screens/Feed";
|
||||||
@@ -447,7 +448,7 @@ function App() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Shell />;
|
return <AppLockGate><Shell /></AppLockGate>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { call, isTauri } from "../ipc";
|
||||||
|
|
||||||
|
export interface AppLockStatus {
|
||||||
|
enabled: boolean;
|
||||||
|
locked: boolean;
|
||||||
|
available: boolean;
|
||||||
|
unavailableReason: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const appLockStatus = (): Promise<AppLockStatus> =>
|
||||||
|
isTauri
|
||||||
|
? call<AppLockStatus>("app_lock_status")
|
||||||
|
: Promise.resolve({ enabled: false, locked: false, available: false, unavailableReason: null });
|
||||||
|
|
||||||
|
export const appUnlock = () => call<void>("app_unlock");
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { call, isTauri } from "../ipc";
|
||||||
|
|
||||||
|
export interface OtpAutofillStatus {
|
||||||
|
available: boolean;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const otpAutofillStatus = (): Promise<OtpAutofillStatus> =>
|
||||||
|
isTauri
|
||||||
|
? call<OtpAutofillStatus>("otp_autofill_status")
|
||||||
|
: Promise.resolve({ available: false, enabled: false });
|
||||||
|
|
||||||
|
export const otpAutofillEnable = (): Promise<boolean> =>
|
||||||
|
isTauri ? call<boolean>("otp_autofill_enable") : Promise.resolve(false);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { call } from "../ipc";
|
||||||
|
|
||||||
|
export interface ProofIssue {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
kind: "spelling" | "grammar";
|
||||||
|
message: string;
|
||||||
|
suggestions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const proofText = (text: string, spelling: boolean, grammar: boolean) =>
|
||||||
|
call<ProofIssue[]>("proof_text", { text, spelling, grammar });
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
import {
|
import {
|
||||||
call,
|
call,
|
||||||
type Draft,
|
type Draft,
|
||||||
|
type DraftAttachment,
|
||||||
type DraftSaved,
|
type DraftSaved,
|
||||||
type InviteResponse,
|
type InviteResponse,
|
||||||
type Outgoing,
|
type Outgoing,
|
||||||
type Undo,
|
type Undo,
|
||||||
} from "../ipc";
|
} from "../ipc";
|
||||||
|
|
||||||
|
export const draftAttachmentStore = (accountId: string, file: DraftAttachment, dataBase64: string) =>
|
||||||
|
call<DraftAttachment>("draft_attachment_store", { accountId, filename: file.filename, mimeType: file.mimeType, dataBase64 });
|
||||||
|
|
||||||
/** Saves locally on every keystroke's debounce and to the provider every few seconds. */
|
/** Saves locally on every keystroke's debounce and to the provider every few seconds. */
|
||||||
export const draftSave = (draft: Draft) => call<DraftSaved>("draft_save", { draft });
|
export const draftSave = (draft: Draft) => call<DraftSaved>("draft_save", { draft });
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { call, isMacDesktop } from "../ipc";
|
||||||
|
|
||||||
|
export const writingToolsAvailable = () =>
|
||||||
|
isMacDesktop ? call<boolean>("writing_tools_available") : Promise.resolve(false);
|
||||||
|
|
||||||
|
export const runWritingTool = (tool: "Proofread" | "Rewrite") =>
|
||||||
|
call<void>("run_writing_tool", { tool });
|
||||||
@@ -1268,6 +1268,11 @@ export const devSettings: Settings = {
|
|||||||
|
|
||||||
remoteImages: "ask",
|
remoteImages: "ask",
|
||||||
linkCleaning: true,
|
linkCleaning: true,
|
||||||
|
otpAutofillEnabled: false,
|
||||||
|
appLockEnabled: false,
|
||||||
|
spellingEnabled: true,
|
||||||
|
grammarEnabled: false,
|
||||||
|
writingToolsEnabled: false,
|
||||||
|
|
||||||
screenerEnabled: true,
|
screenerEnabled: true,
|
||||||
holdReplies: false,
|
holdReplies: false,
|
||||||
|
|||||||
+21
-6
@@ -287,15 +287,16 @@ interface UndoEntry {
|
|||||||
token: string;
|
token: string;
|
||||||
label: string;
|
label: string;
|
||||||
revert: () => void;
|
revert: () => void;
|
||||||
|
outgoingId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const undoStack: UndoEntry[] = [];
|
const undoStack: UndoEntry[] = [];
|
||||||
let undoCount = 0;
|
let undoCount = 0;
|
||||||
|
|
||||||
function undoable(label: string, revert: () => void, undoMs = 0): Undo {
|
function undoable(label: string, revert: () => void, undoMs = 0, outgoingId?: string): Undo {
|
||||||
undoCount += 1;
|
undoCount += 1;
|
||||||
const token = `undo-${undoCount}`;
|
const token = `undo-${undoCount}`;
|
||||||
undoStack.push({ token, label, revert });
|
undoStack.push({ token, label, revert, outgoingId });
|
||||||
// Bounded in Rust for the same reason: a stack that grows for a session is a leak.
|
// Bounded in Rust for the same reason: a stack that grows for a session is a leak.
|
||||||
if (undoStack.length > 25) undoStack.shift();
|
if (undoStack.length > 25) undoStack.shift();
|
||||||
return { token, label, undoMs };
|
return { token, label, undoMs };
|
||||||
@@ -1451,6 +1452,15 @@ export async function mockCall<T>(command: string, args?: Record<string, unknown
|
|||||||
// ---------------------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------------------
|
||||||
// Writing
|
// Writing
|
||||||
// ---------------------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------------------
|
||||||
|
case "draft_list": {
|
||||||
|
const accountId = arg<string | null>("accountId");
|
||||||
|
return done(
|
||||||
|
empty ? [] : drafts
|
||||||
|
.filter((draft) => !accountId || draft.accountId === accountId)
|
||||||
|
.map((draft) => structuredClone(draft)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
case "draft_save": {
|
case "draft_save": {
|
||||||
const draft = arg<Draft>("draft");
|
const draft = arg<Draft>("draft");
|
||||||
const id = draft.id ?? `draft-${drafts.length + 1}`;
|
const id = draft.id ?? `draft-${drafts.length + 1}`;
|
||||||
@@ -1462,7 +1472,7 @@ export async function mockCall<T>(command: string, args?: Record<string, unknown
|
|||||||
const thread = byKey(draft.threadKey);
|
const thread = byKey(draft.threadKey);
|
||||||
if (thread) thread.hasDraft = true;
|
if (thread) thread.hasDraft = true;
|
||||||
}
|
}
|
||||||
changed("threads");
|
changed("drafts threads");
|
||||||
const encodedSize = Math.round(draft.bodyHtml.length * 1.37) + 2_048;
|
const encodedSize = Math.round(draft.bodyHtml.length * 1.37) + 2_048;
|
||||||
return done({
|
return done({
|
||||||
id,
|
id,
|
||||||
@@ -1486,7 +1496,7 @@ export async function mockCall<T>(command: string, args?: Record<string, unknown
|
|||||||
const thread = gone?.threadKey ? byKey(gone.threadKey) : undefined;
|
const thread = gone?.threadKey ? byKey(gone.threadKey) : undefined;
|
||||||
if (thread) thread.hasDraft = false;
|
if (thread) thread.hasDraft = false;
|
||||||
}
|
}
|
||||||
changed("threads");
|
changed("drafts threads");
|
||||||
return done(undefined);
|
return done(undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1510,7 +1520,9 @@ export async function mockCall<T>(command: string, args?: Record<string, unknown
|
|||||||
thread.hasDraft = false;
|
thread.hasDraft = false;
|
||||||
thread.pile = null;
|
thread.pile = null;
|
||||||
}
|
}
|
||||||
changed("outbox threads");
|
const savedIndex = drafts.findIndex((saved) => saved.id === draft.id);
|
||||||
|
const saved = savedIndex >= 0 ? drafts.splice(savedIndex, 1)[0] : undefined;
|
||||||
|
changed("outbox drafts threads");
|
||||||
const to = draft.to[0]?.name ?? draft.to[0]?.address ?? "nobody";
|
const to = draft.to[0]?.name ?? draft.to[0]?.address ?? "nobody";
|
||||||
return done(
|
return done(
|
||||||
undoable(
|
undoable(
|
||||||
@@ -1518,18 +1530,21 @@ export async function mockCall<T>(command: string, args?: Record<string, unknown
|
|||||||
() => {
|
() => {
|
||||||
const index = outbox.findIndex((item) => item.id === id);
|
const index = outbox.findIndex((item) => item.id === id);
|
||||||
if (index >= 0) outbox.splice(index, 1);
|
if (index >= 0) outbox.splice(index, 1);
|
||||||
|
if (saved) drafts.push(saved);
|
||||||
if (thread) {
|
if (thread) {
|
||||||
thread.sending = false;
|
thread.sending = false;
|
||||||
thread.hasDraft = true;
|
thread.hasDraft = true;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
settings.undoDelaySecs * 1_000,
|
settings.undoDelaySecs * 1_000,
|
||||||
|
id,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
case "send_now": {
|
case "send_now": {
|
||||||
const id = arg<string>("outgoingId");
|
const outgoingId = arg<string>("outgoingId");
|
||||||
|
const id = undoStack.find((entry) => entry.token === outgoingId)?.outgoingId ?? outgoingId;
|
||||||
// Rust pushes the message to the provider before it answers, and the line has a busy state
|
// Rust pushes the message to the provider before it answers, and the line has a busy state
|
||||||
// for that wait: the same beat as the bodies, so it can be looked at.
|
// for that wait: the same beat as the bodies, so it can be looked at.
|
||||||
if (flagged("marginmail-dev-pending")) await beat(900);
|
if (flagged("marginmail-dev-pending")) await beat(900);
|
||||||
|
|||||||
@@ -622,6 +622,11 @@ export interface Settings {
|
|||||||
/** The per sender allowances are not here: they live on the contact and roam with it. */
|
/** The per sender allowances are not here: they live on the contact and roam with it. */
|
||||||
remoteImages: "never" | "ask" | "always";
|
remoteImages: "never" | "ask" | "always";
|
||||||
linkCleaning: boolean;
|
linkCleaning: boolean;
|
||||||
|
otpAutofillEnabled: boolean;
|
||||||
|
appLockEnabled: boolean;
|
||||||
|
spellingEnabled: boolean;
|
||||||
|
grammarEnabled: boolean;
|
||||||
|
writingToolsEnabled: boolean;
|
||||||
|
|
||||||
screenerEnabled: boolean;
|
screenerEnabled: boolean;
|
||||||
holdReplies: boolean;
|
holdReplies: boolean;
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { useEffect, useState, type ReactNode } from "react";
|
||||||
|
import { appLockStatus, appUnlock, type AppLockStatus } from "../api/appLock";
|
||||||
|
import { Button } from "../ui/Button";
|
||||||
|
import "./app-lock.css";
|
||||||
|
|
||||||
|
export function AppLockGate({ children }: { children: ReactNode }) {
|
||||||
|
const [status, setStatus] = useState<AppLockStatus | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const readStatus = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
setStatus(await appLockStatus());
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void readStatus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const unlock = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await appUnlock();
|
||||||
|
setStatus(await appLockStatus());
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (status && !status.locked) return children;
|
||||||
|
if (!status && !error) return <div className="app" aria-busy="true" />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app app-lock">
|
||||||
|
<main className="app-lock-content">
|
||||||
|
<h1 className="app-lock-title">Mailbox locked</h1>
|
||||||
|
<p className="app-lock-description">
|
||||||
|
Use Touch ID or your Mac login password to open your mailbox.
|
||||||
|
</p>
|
||||||
|
{error || status?.unavailableReason ? (
|
||||||
|
<p className="app-lock-error" role="alert">{error ?? status?.unavailableReason}</p>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => void (status?.available ? unlock() : readStatus())}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
{busy ? "Waiting for macOS…" : status?.available ? "Unlock mailbox" : "Try again"}
|
||||||
|
</Button>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+48
-19
@@ -15,9 +15,11 @@ import { useEscapeLayer } from "../escape";
|
|||||||
import { registerCommands } from "../keys/commands";
|
import { registerCommands } from "../keys/commands";
|
||||||
import { useKeyContext } from "../keys/keymap";
|
import { useKeyContext } from "../keys/keymap";
|
||||||
import { contactsSuggest } from "../api/contacts";
|
import { contactsSuggest } from "../api/contacts";
|
||||||
|
import { draftAttachmentStore } from "../api/write";
|
||||||
|
import { notify } from "../store/useToast";
|
||||||
import { isTauri, type Draft, type DraftAttachment, type Person } from "../ipc";
|
import { isTauri, type Draft, type DraftAttachment, type Person } from "../ipc";
|
||||||
import { useAccounts } from "../store/useAccounts";
|
import { useAccounts } from "../store/useAccounts";
|
||||||
import { useCompose, type Composer, type ComposerAt } from "../store/useCompose";
|
import { storeAttachments, useCompose, type Composer, type ComposerAt } from "../store/useCompose";
|
||||||
import { useMail } from "../store/useMail";
|
import { useMail } from "../store/useMail";
|
||||||
import { useSettings } from "../store/useSettings";
|
import { useSettings } from "../store/useSettings";
|
||||||
import { Editor } from "./Editor";
|
import { Editor } from "./Editor";
|
||||||
@@ -139,35 +141,63 @@ function defaultReminder(): number {
|
|||||||
// Files
|
// Files
|
||||||
// -------------------------------------------------------------------------------------------
|
// -------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
|
||||||
* The webview's own picker, which is the only one this app has: there is no dialog plugin in
|
|
||||||
* `src-tauri`, and adding one is a decision for whoever owns that crate.
|
|
||||||
*
|
|
||||||
* A `File` from a webview has a name, a size and a type and no path, and `DraftAttachment` carries
|
|
||||||
* a path or a mirror attachment id and nothing else. So the name goes in the path field, which is
|
|
||||||
* enough for the fixture and for the composer's own arithmetic and is not enough for Rust to read
|
|
||||||
* the bytes. Dragging a file onto a Tauri window is the route that carries a real path.
|
|
||||||
*/
|
|
||||||
export function pickFiles(at: ComposerAt): void {
|
export function pickFiles(at: ComposerAt): void {
|
||||||
const input = document.createElement("input");
|
const input = document.createElement("input");
|
||||||
input.type = "file";
|
input.type = "file";
|
||||||
input.multiple = true;
|
input.multiple = true;
|
||||||
input.style.display = "none";
|
input.style.display = "none";
|
||||||
input.addEventListener("change", () => {
|
input.addEventListener("change", () => {
|
||||||
useCompose.getState().attach(at, [...(input.files ?? [])].map(asAttachment));
|
void attachFiles(at, [...(input.files ?? [])]);
|
||||||
input.remove();
|
input.remove();
|
||||||
});
|
});
|
||||||
|
input.addEventListener("cancel", () => input.remove());
|
||||||
document.body.append(input);
|
document.body.append(input);
|
||||||
input.click();
|
input.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
export const asAttachment = (file: File): DraftAttachment => ({
|
export const asAttachment = (file: File): DraftAttachment => ({
|
||||||
path: file.name,
|
|
||||||
filename: file.name,
|
filename: file.name,
|
||||||
mimeType: file.type || "application/octet-stream",
|
mimeType: file.type || "application/octet-stream",
|
||||||
size: file.size,
|
size: file.size,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function attachFiles(at: ComposerAt, files: File[]): Promise<void> {
|
||||||
|
return storeAttachments(at, persistFiles(at, files)).catch((error) => {
|
||||||
|
notify(`Could not attach files: ${error}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistFiles(at: ComposerAt, files: File[]): Promise<void> {
|
||||||
|
const composer = at === "card" ? useCompose.getState().card : useCompose.getState().reply;
|
||||||
|
if (!composer || files.length === 0) return;
|
||||||
|
const existingSize = Math.max(composer.encodedSize,
|
||||||
|
(composer.draft.attachments ?? []).reduce((size, file) => size + file.size * 4 / 3, 0));
|
||||||
|
if (existingSize + files.reduce((size, file) => size + file.size * 4 / 3, 0) > 35 * 1024 * 1024) {
|
||||||
|
throw new Error("These files would put this message over the 35 MB the provider takes");
|
||||||
|
}
|
||||||
|
const draftId = composer.draft.id || crypto.randomUUID();
|
||||||
|
if (!composer.draft.id) useCompose.getState().edit(at, { id: draftId });
|
||||||
|
const attachments: DraftAttachment[] = [];
|
||||||
|
for (const file of files) {
|
||||||
|
const descriptor = asAttachment(file);
|
||||||
|
if (!isTauri) {
|
||||||
|
attachments.push(descriptor);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const data = await new Promise<string>((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(String(reader.result).split(",")[1]);
|
||||||
|
reader.onerror = () => reject(reader.error ?? new Error(`${file.name} could not be read`));
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
attachments.push(await draftAttachmentStore(composer.draft.accountId, descriptor, data));
|
||||||
|
}
|
||||||
|
const current = at === "card" ? useCompose.getState().card : useCompose.getState().reply;
|
||||||
|
if (current?.draft.id === draftId) {
|
||||||
|
useCompose.getState().attach(at, attachments);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------------------------
|
// -------------------------------------------------------------------------------------------
|
||||||
// The card
|
// The card
|
||||||
// -------------------------------------------------------------------------------------------
|
// -------------------------------------------------------------------------------------------
|
||||||
@@ -232,6 +262,7 @@ function ComposeCard({ composer, expanded, onClose, onExpand }: ComposeCardProps
|
|||||||
const draft = composer.draft;
|
const draft = composer.draft;
|
||||||
const edit = useCompose((s) => s.edit);
|
const edit = useCompose((s) => s.edit);
|
||||||
const setShowCc = useCompose((s) => s.setShowCc);
|
const setShowCc = useCompose((s) => s.setShowCc);
|
||||||
|
const signature = useSettings((s) => s.settings?.accounts.find((account) => account.accountId === draft.accountId)?.signature);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -314,6 +345,7 @@ function ComposeCard({ composer, expanded, onClose, onExpand }: ComposeCardProps
|
|||||||
html={draft.bodyHtml}
|
html={draft.bodyHtml}
|
||||||
label="Message"
|
label="Message"
|
||||||
placeholder="Write your message"
|
placeholder="Write your message"
|
||||||
|
signature={signature}
|
||||||
onChange={(bodyHtml) => edit("card", { bodyHtml })}
|
onChange={(bodyHtml) => edit("card", { bodyHtml })}
|
||||||
/>
|
/>
|
||||||
<Attachments at="card" files={draft.attachments ?? []} />
|
<Attachments at="card" files={draft.attachments ?? []} />
|
||||||
@@ -336,19 +368,14 @@ export function dropped(e: DragEvent<HTMLElement>, at: ComposerAt): void {
|
|||||||
const files = [...(e.dataTransfer.files ?? [])];
|
const files = [...(e.dataTransfer.files ?? [])];
|
||||||
if (files.length === 0) return;
|
if (files.length === 0) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
useCompose.getState().attach(at, files.map(asAttachment));
|
void attachFiles(at, files);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A pasted image becomes an attachment rather than an inline part, because `DraftAttachment` has a
|
|
||||||
* path or a mirror attachment id and no content id and no inline flag. That is the contract rather
|
|
||||||
* than an oversight to route around here: an inline image needs a `cid:` on both sides.
|
|
||||||
*/
|
|
||||||
export function pasted(e: ClipboardEvent<HTMLElement>, at: ComposerAt): void {
|
export function pasted(e: ClipboardEvent<HTMLElement>, at: ComposerAt): void {
|
||||||
const files = [...(e.clipboardData?.files ?? [])];
|
const files = [...(e.clipboardData?.files ?? [])];
|
||||||
if (files.length === 0) return;
|
if (files.length === 0) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
useCompose.getState().attach(at, files.map(asAttachment));
|
void attachFiles(at, files);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FromFieldProps {
|
interface FromFieldProps {
|
||||||
@@ -727,6 +754,7 @@ export function ReplyBox({ to, composer }: ReplyBoxProps) {
|
|||||||
const [closing, setClosing] = useState(false);
|
const [closing, setClosing] = useState(false);
|
||||||
const editor = useRef<TiptapEditor | null>(null);
|
const editor = useRef<TiptapEditor | null>(null);
|
||||||
const draft = composer.draft;
|
const draft = composer.draft;
|
||||||
|
const signature = useSettings((s) => s.settings?.accounts.find((account) => account.accountId === draft.accountId)?.signature);
|
||||||
|
|
||||||
// Escape leaves the editor and keeps the draft, which is docs/keyboard.md's own wording. The
|
// Escape leaves the editor and keeps the draft, which is docs/keyboard.md's own wording. The
|
||||||
// second Escape is not this box's: it belongs to whatever is under it.
|
// second Escape is not this box's: it belongs to whatever is under it.
|
||||||
@@ -810,6 +838,7 @@ export function ReplyBox({ to, composer }: ReplyBoxProps) {
|
|||||||
html={draft.bodyHtml}
|
html={draft.bodyHtml}
|
||||||
label="Reply"
|
label="Reply"
|
||||||
placeholder="Write a reply"
|
placeholder="Write a reply"
|
||||||
|
signature={signature}
|
||||||
autoFocus={!forwarding}
|
autoFocus={!forwarding}
|
||||||
onChange={(bodyHtml) => edit("reply", { bodyHtml })}
|
onChange={(bodyHtml) => edit("reply", { bodyHtml })}
|
||||||
onReady={(instance) => {
|
onReady={(instance) => {
|
||||||
|
|||||||
+224
-46
@@ -1,94 +1,272 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
import { EditorContent, useEditor, type Editor as TiptapEditor } from "@tiptap/react";
|
import { EditorContent, useEditor, useEditorState, type Editor as TiptapEditor } from "@tiptap/react";
|
||||||
|
import { Extension } from "@tiptap/core";
|
||||||
|
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||||
|
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
||||||
import StarterKit from "@tiptap/starter-kit";
|
import StarterKit from "@tiptap/starter-kit";
|
||||||
import { hasText } from "../store/useCompose";
|
import { hasText } from "../store/useCompose";
|
||||||
|
import { useSettings } from "../store/useSettings";
|
||||||
|
import { isMacDesktop } from "../ipc";
|
||||||
|
import { proofText, type ProofIssue as NativeProofIssue } from "../api/proofing";
|
||||||
|
import { Icon, icons } from "../ui";
|
||||||
|
import { useEscapeLayer } from "../escape";
|
||||||
|
import { signatureHtml } from "../signature";
|
||||||
|
import { writingToolsAvailable, runWritingTool } from "../api/writingtools";
|
||||||
import "./editor.css";
|
import "./editor.css";
|
||||||
|
|
||||||
/**
|
|
||||||
* The body of a message, which is TipTap and StarterKit and nothing else.
|
|
||||||
*
|
|
||||||
* Paragraphs, bold, italic, links, lists, quotes and code, built the way margin builds its editor
|
|
||||||
* in `src/editor/extensions.ts`: one configured StarterKit rather than a list of extensions
|
|
||||||
* assembled by hand. What is deliberately absent is colour and type. A mail client that lets you
|
|
||||||
* choose a typeface is a mail client that sends mail nobody can read, and the faces in settings are
|
|
||||||
* the reader's choice rather than the writer's.
|
|
||||||
*
|
|
||||||
* There is no toolbar either. Every mark this editor can make has a key in docs/keyboard.md, the
|
|
||||||
* keys are TipTap's own, and `src/keys/bindings.ts` declares them with a null command so the
|
|
||||||
* dispatcher sees the frame and stands out of the way. `Cmd+K` is the one real collision in the
|
|
||||||
* app, and inside here the link wins because the palette is one Escape away and a link is not.
|
|
||||||
*
|
|
||||||
* It writes HTML. Rust inlines the stylesheet and builds the plain text alternative on the way out,
|
|
||||||
* which is why nothing here has an opinion about what the recipient's client can render.
|
|
||||||
*/
|
|
||||||
|
|
||||||
interface EditorProps {
|
interface EditorProps {
|
||||||
html: string;
|
html: string;
|
||||||
onChange: (html: string) => void;
|
onChange: (html: string) => void;
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
label: string;
|
label: string;
|
||||||
/**
|
|
||||||
* Takes the caret when it appears, which is what `r` is for.
|
|
||||||
*
|
|
||||||
* At the start of the document rather than the end, because a draft opens with a signature under
|
|
||||||
* it and nobody writes underneath their own name.
|
|
||||||
*/
|
|
||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
/** Handed the instance so the box around it can put the caret back. */
|
|
||||||
onReady?: (editor: TiptapEditor | null) => void;
|
onReady?: (editor: TiptapEditor | null) => void;
|
||||||
|
signature?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EXTENSIONS = [
|
interface ProofIssue extends NativeProofIssue {
|
||||||
StarterKit.configure({
|
from: number;
|
||||||
// Mail has no headings and no rules. The subject is the heading and the hairline between
|
to: number;
|
||||||
// messages is the pane's, so both would be a mark the reader meets somewhere it means nothing.
|
word: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const proofKey = new PluginKey<DecorationSet>("mail-proofing");
|
||||||
|
const Proofing = Extension.create({
|
||||||
|
name: "mailProofing",
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
return [new Plugin({
|
||||||
|
key: proofKey,
|
||||||
|
state: {
|
||||||
|
init: () => DecorationSet.empty,
|
||||||
|
apply: (transaction, decorations) => {
|
||||||
|
const issues = transaction.getMeta(proofKey) as ProofIssue[] | undefined;
|
||||||
|
if (issues) return DecorationSet.create(transaction.doc, issues.map((issue) =>
|
||||||
|
Decoration.inline(issue.from, issue.to, { class: `editor-proof-${issue.kind}` })));
|
||||||
|
return transaction.docChanged ? DecorationSet.empty : decorations;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
props: { decorations: (state) => proofKey.getState(state) },
|
||||||
|
})];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const EXTENSIONS = [StarterKit.configure({
|
||||||
heading: false,
|
heading: false,
|
||||||
horizontalRule: false,
|
horizontalRule: false,
|
||||||
link: { openOnClick: false, autolink: true, defaultProtocol: "https" },
|
link: { openOnClick: false, autolink: true, defaultProtocol: "https" },
|
||||||
}),
|
}), Proofing];
|
||||||
];
|
|
||||||
|
|
||||||
export function Editor({ html, onChange, placeholder, label, autoFocus, onReady }: EditorProps) {
|
export function Editor({ html, onChange, placeholder, label, autoFocus, onReady, signature }: EditorProps) {
|
||||||
const emitted = useRef(html);
|
const emitted = useRef(html);
|
||||||
const ready = useRef(onReady);
|
const ready = useRef(onReady);
|
||||||
ready.current = onReady;
|
ready.current = onReady;
|
||||||
|
const spelling = useSettings((state) => state.settings?.spellingEnabled ?? true);
|
||||||
|
const grammar = useSettings((state) => state.settings?.grammarEnabled ?? false);
|
||||||
|
const writingTools = useSettings((state) => state.settings?.writingToolsEnabled ?? false);
|
||||||
|
const [appleToolsAvailable, setAppleToolsAvailable] = useState(false);
|
||||||
|
const [writingToolsError, setWritingToolsError] = useState("");
|
||||||
|
const [linkOpen, setLinkOpen] = useState(false);
|
||||||
|
const [linkValue, setLinkValue] = useState("");
|
||||||
|
const [linkError, setLinkError] = useState("");
|
||||||
|
const [issues, setIssues] = useState<ProofIssue[]>([]);
|
||||||
|
const [proofOpen, setProofOpen] = useState(false);
|
||||||
|
const [proofError, setProofError] = useState("");
|
||||||
|
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
extensions: EXTENSIONS,
|
extensions: EXTENSIONS,
|
||||||
content: html,
|
content: html,
|
||||||
autofocus: autoFocus ? "start" : false,
|
autofocus: autoFocus ? "start" : false,
|
||||||
editorProps: {
|
editorProps: { attributes: { class: "editor-body", "aria-label": label, spellcheck: String(!isMacDesktop && spelling) } },
|
||||||
attributes: { class: "editor-body", "aria-label": label },
|
|
||||||
},
|
|
||||||
onUpdate: ({ editor }) => {
|
onUpdate: ({ editor }) => {
|
||||||
emitted.current = editor.getHTML();
|
emitted.current = editor.getHTML();
|
||||||
onChange(emitted.current);
|
onChange(emitted.current);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEditorState({ editor, selector: ({ editor }) => editor?.state });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let live = true;
|
||||||
|
setAppleToolsAvailable(false);
|
||||||
|
if (writingTools && isMacDesktop) {
|
||||||
|
void writingToolsAvailable().then((available) => { if (live) setAppleToolsAvailable(available); })
|
||||||
|
.catch((error) => { if (live) setWritingToolsError(String(error)); });
|
||||||
|
}
|
||||||
|
return () => { live = false; };
|
||||||
|
}, [writingTools]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
editor?.view.dom.setAttribute("spellcheck", String(!isMacDesktop && spelling));
|
||||||
|
}, [editor, spelling]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
ready.current?.(editor ?? null);
|
ready.current?.(editor ?? null);
|
||||||
return () => ready.current?.(null);
|
return () => ready.current?.(null);
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
// Written from outside: Instant intro puts a line at the top and pressing it again takes the line
|
|
||||||
// away. Comparing against what this editor last emitted rather than against its own document is
|
|
||||||
// what keeps that from fighting the caret on every keystroke.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor || html === emitted.current) return;
|
if (!editor || html === emitted.current) return;
|
||||||
emitted.current = html;
|
emitted.current = html;
|
||||||
editor.commands.setContent(html, { emitUpdate: false });
|
editor.commands.setContent(html, { emitUpdate: false });
|
||||||
}, [editor, html]);
|
}, [editor, html]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor) return;
|
||||||
|
let version = 0;
|
||||||
|
let timer: ReturnType<typeof setTimeout>;
|
||||||
|
const proof = () => {
|
||||||
|
const current = ++version;
|
||||||
|
clearTimeout(timer);
|
||||||
|
setIssues([]);
|
||||||
|
editor.view.dispatch(editor.state.tr.setMeta(proofKey, []));
|
||||||
|
if (!isMacDesktop || (!spelling && !grammar)) return;
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
const doc = editor.state.doc;
|
||||||
|
const segments: { offset: number; position: number; text: string }[] = [];
|
||||||
|
let text = "";
|
||||||
|
doc.descendants((node, position) => {
|
||||||
|
if (!node.isTextblock) return true;
|
||||||
|
if (text) text += "\n\n";
|
||||||
|
node.forEach((child, offset) => {
|
||||||
|
if (child.type.name === "hardBreak") { text += "\n"; return; }
|
||||||
|
if (!child.isText) return;
|
||||||
|
segments.push({ offset: text.length, position: position + 1 + offset, text: child.text ?? "" });
|
||||||
|
text += child.text ?? "";
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
if (!text.trim()) return;
|
||||||
|
const positionAt = (offset: number) => {
|
||||||
|
const segment = segments.find((segment) => offset >= segment.offset && offset <= segment.offset + segment.text.length);
|
||||||
|
return segment ? segment.position + offset - segment.offset : null;
|
||||||
|
};
|
||||||
|
void proofText(text, spelling, grammar).then((found) => {
|
||||||
|
if (current !== version || editor.isDestroyed || !editor.state.doc.eq(doc)) return;
|
||||||
|
const mapped = found.flatMap((issue) => {
|
||||||
|
const from = positionAt(issue.start);
|
||||||
|
const to = positionAt(issue.end);
|
||||||
|
return from !== null && to !== null && to > from ? [{ ...issue, from, to, word: text.slice(issue.start, issue.end) }] : [];
|
||||||
|
});
|
||||||
|
setIssues(mapped);
|
||||||
|
setProofError("");
|
||||||
|
editor.view.dispatch(editor.state.tr.setMeta(proofKey, mapped));
|
||||||
|
}).catch((error) => {
|
||||||
|
if (current === version) setProofError(String(error));
|
||||||
|
});
|
||||||
|
}, 650);
|
||||||
|
};
|
||||||
|
proof();
|
||||||
|
editor.on("update", proof);
|
||||||
|
return () => { version++; clearTimeout(timer); editor.off("update", proof); };
|
||||||
|
}, [editor, spelling, grammar]);
|
||||||
|
|
||||||
|
useEscapeLayer(linkOpen || proofOpen, () => { setLinkOpen(false); setProofOpen(false); editor?.commands.focus(); });
|
||||||
|
|
||||||
|
const openLink = () => {
|
||||||
|
setLinkValue((editor?.getAttributes("link").href as string) ?? "");
|
||||||
|
setLinkError("");
|
||||||
|
setLinkOpen(true);
|
||||||
|
setProofOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor) return;
|
||||||
|
const key = (event: KeyboardEvent) => {
|
||||||
|
if (!editor.isFocused || event.key.toLowerCase() !== "k" || !(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
openLink();
|
||||||
|
};
|
||||||
|
editor.view.dom.addEventListener("keydown", key);
|
||||||
|
return () => editor.view.dom.removeEventListener("keydown", key);
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
const applyLink = () => {
|
||||||
|
if (!editor) return;
|
||||||
|
const value = linkValue.trim();
|
||||||
|
if (!value) editor.chain().focus().extendMarkRange("link").unsetLink().run();
|
||||||
|
else {
|
||||||
|
const href = /^[a-z][a-z\d+.-]*:/i.test(value) ? value : `https://${value}`;
|
||||||
|
try {
|
||||||
|
const url = new URL(href);
|
||||||
|
if (!["http:", "https:", "mailto:", "tel:"].includes(url.protocol)) throw new Error();
|
||||||
|
} catch { setLinkError("Use a web, email, or telephone link."); return; }
|
||||||
|
if (editor.state.selection.empty && !editor.isActive("link")) {
|
||||||
|
editor.chain().focus().insertContent({ type: "text", text: value, marks: [{ type: "link", attrs: { href } }] }).run();
|
||||||
|
} else editor.chain().focus().extendMarkRange("link").setLink({ href }).run();
|
||||||
|
}
|
||||||
|
setLinkOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const tool = (title: string, content: ReactNode, action: () => void, active = false, disabled = false) => (
|
||||||
|
<button type="button" className="editor-tool" title={title} aria-label={title} aria-pressed={active}
|
||||||
|
disabled={disabled} onMouseDown={(event) => event.preventDefault()} onClick={action}>{content}</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
const insertSignature = () => {
|
||||||
|
if (!editor || !signature) return;
|
||||||
|
editor.chain().focus().insertContent(signatureHtml(signature)).run();
|
||||||
|
};
|
||||||
|
|
||||||
|
const runAppleTool = (tool: "Proofread" | "Rewrite") => {
|
||||||
|
if (!editor) return;
|
||||||
|
setWritingToolsError("");
|
||||||
|
if (editor.state.selection.empty) editor.chain().focus().selectAll().run();
|
||||||
|
else editor.commands.focus();
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
void runWritingTool(tool).catch((error) => setWritingToolsError(String(error)));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="editor" data-empty={hasText(html) ? undefined : ""}>
|
<div className="editor">
|
||||||
{/* The placeholder is a sibling rather than the Placeholder extension, which is a dependency
|
{editor ? <>
|
||||||
this app does not have and would be carrying for one line of grey text. */}
|
<div className="editor-toolbar" role="toolbar" aria-label="Writing tools">
|
||||||
<span className="editor-placeholder" aria-hidden="true">
|
{tool("Bold (⌘B)", <b>B</b>, () => editor.chain().focus().toggleBold().run(), editor.isActive("bold"))}
|
||||||
{placeholder}
|
{tool("Italic (⌘I)", <i>I</i>, () => editor.chain().focus().toggleItalic().run(), editor.isActive("italic"))}
|
||||||
</span>
|
{tool("Underline (⌘U)", <u>U</u>, () => editor.chain().focus().toggleUnderline().run(), editor.isActive("underline"))}
|
||||||
|
{tool("Strikethrough", <s>S</s>, () => editor.chain().focus().toggleStrike().run(), editor.isActive("strike"))}
|
||||||
|
<span className="editor-tool-separator" />
|
||||||
|
{tool("Bulleted list", <Icon d="M8 6h12M8 12h12M8 18h12M3 6h.01M3 12h.01M3 18h.01" />, () => editor.chain().focus().toggleBulletList().run(), editor.isActive("bulletList"))}
|
||||||
|
{tool("Numbered list", <Icon d="M9 6h12M9 12h12M9 18h12M3 4h1v4M3 11c3-2 3 1 0 3h3M3 17h3l-2 2 2 1H3" />, () => editor.chain().focus().toggleOrderedList().run(), editor.isActive("orderedList"))}
|
||||||
|
{tool("Quote", <Icon d="M7 8h4v4a4 4 0 0 1-4 4M14 8h4v4a4 4 0 0 1-4 4" />, () => editor.chain().focus().toggleBlockquote().run(), editor.isActive("blockquote"))}
|
||||||
|
{tool("Link (⌘K)", <Icon d="M10 13a5 5 0 0 0 7 0l2-2a5 5 0 0 0-7-7l-1 1M14 11a5 5 0 0 0-7 0l-2 2a5 5 0 0 0 7 7l1-1" />, openLink, editor.isActive("link") || linkOpen)}
|
||||||
|
{tool("Clear formatting", <Icon d="M4 4h14M11 4l-4 15M14 14l6 6M20 14l-6 6" />, () => editor.chain().focus().unsetAllMarks().clearNodes().run())}
|
||||||
|
<span className="editor-tool-separator" />
|
||||||
|
{tool("Undo (⌘Z)", <Icon d={icons.REPLY} />, () => editor.chain().focus().undo().run(), false, !editor.can().undo())}
|
||||||
|
{tool("Redo (⌘⇧Z)", <Icon d={icons.FORWARD} />, () => editor.chain().focus().redo().run(), false, !editor.can().redo())}
|
||||||
|
{signature?.trim() ? tool("Insert signature", <Icon d={icons.PEN} />, insertSignature) : null}
|
||||||
|
{writingTools && appleToolsAvailable ? <>
|
||||||
|
{tool("Proofread with Apple Writing Tools", "Proofread", () => runAppleTool("Proofread"), false, !hasText(html))}
|
||||||
|
{tool("Rewrite with Apple Writing Tools", "Rewrite", () => runAppleTool("Rewrite"), false, !hasText(html))}
|
||||||
|
</> : null}
|
||||||
|
{isMacDesktop && (spelling || grammar) ? tool(`Spelling and grammar${issues.length ? ` (${issues.length})` : ""}`, <><Icon d="M3 17 8 5l5 12M5 12h6M14 14l3 3 5-6" />{issues.length ? <span>{issues.length}</span> : null}</>, () => { setProofOpen(!proofOpen); setLinkOpen(false); }, proofOpen) : null}
|
||||||
|
</div>
|
||||||
|
{writingTools && writingToolsError ? <p className="editor-writing-error" role="alert">{writingToolsError}</p> : null}
|
||||||
|
{linkOpen ? <form className="editor-link" onSubmit={(event) => { event.preventDefault(); applyLink(); }}>
|
||||||
|
<input aria-label="Link URL" value={linkValue} placeholder="https://" autoFocus spellCheck={false} onChange={(event) => setLinkValue(event.target.value)} />
|
||||||
|
<button type="submit">Apply</button>
|
||||||
|
{editor.isActive("link") ? <button type="button" onClick={() => { editor.chain().focus().extendMarkRange("link").unsetLink().run(); setLinkOpen(false); }}>Remove</button> : null}
|
||||||
|
<button type="button" aria-label="Close link editor" onClick={() => { setLinkOpen(false); editor.commands.focus(); }}><Icon d={icons.CLOSE} /></button>
|
||||||
|
{linkError ? <span role="alert">{linkError}</span> : null}
|
||||||
|
</form> : null}
|
||||||
|
{proofOpen ? <div className="editor-proof-panel" aria-label="Spelling and grammar suggestions">
|
||||||
|
{proofError ? <p role="alert">Writing check unavailable: {proofError}</p> : issues.length ? issues.map((issue, index) => <div className="editor-proof-issue" key={`${issue.from}:${issue.to}:${index}`}>
|
||||||
|
<button type="button" className="editor-proof-word" onClick={() => editor.chain().focus().setTextSelection({ from: issue.from, to: issue.to }).run()}>{issue.word}</button>
|
||||||
|
<span>{issue.message}</span>
|
||||||
|
{issue.suggestions.map((suggestion, suggestionIndex) => <button type="button" key={suggestionIndex} onClick={() => {
|
||||||
|
const range = { from: issue.from, to: issue.to };
|
||||||
|
if (suggestion) editor.chain().focus().insertContentAt(range, { type: "text", text: suggestion }).run();
|
||||||
|
else editor.chain().focus().deleteRange(range).run();
|
||||||
|
}}>{suggestion || "Delete"}</button>)}
|
||||||
|
</div>) : <p>No suggestions. Checks use your Mac’s available languages.</p>}
|
||||||
|
</div> : null}
|
||||||
|
</> : null}
|
||||||
|
<div className="editor-content" data-empty={hasText(html) ? undefined : ""}>
|
||||||
|
<span className="editor-placeholder" aria-hidden="true">{placeholder}</span>
|
||||||
<EditorContent editor={editor} />
|
<EditorContent editor={editor} />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ export function FocusReply() {
|
|||||||
setAt((was) => Math.min(Math.max(was + delta, 0), Math.max(items.length - 1, 0)));
|
setAt((was) => Math.min(Math.max(was + delta, 0), Math.max(items.length - 1, 0)));
|
||||||
|
|
||||||
/** One item's reply, down the pipeline the other two composers use. */
|
/** One item's reply, down the pipeline the other two composers use. */
|
||||||
const post = (thread: ThreadSummary, now: boolean) => {
|
const post = async (thread: ThreadSummary, now: boolean) => {
|
||||||
const view = views[thread.key];
|
const view = views[thread.key];
|
||||||
const last = view?.messages.at(-1);
|
const last = view?.messages.at(-1);
|
||||||
const body = (drafts[thread.key] ?? "").trim();
|
const body = (drafts[thread.key] ?? "").trim();
|
||||||
@@ -108,7 +108,8 @@ export function FocusReply() {
|
|||||||
.join(""),
|
.join(""),
|
||||||
});
|
});
|
||||||
const to = useCompose.getState().reply?.draft.to[0];
|
const to = useCompose.getState().reply?.draft.to[0];
|
||||||
void compose.post("reply", now);
|
await compose.post("reply", now);
|
||||||
|
if (useCompose.getState().reply) return;
|
||||||
setSent((was) => ({ ...was, [thread.key]: to ? displayName(to) : displayName(thread.from) }));
|
setSent((was) => ({ ...was, [thread.key]: to ? displayName(to) : displayName(thread.from) }));
|
||||||
step(1);
|
step(1);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { accountRemove, accountSetColor, accountSetName } from "../api/accounts"
|
|||||||
import { backupConfigure, backupNow, backupPhrase, backupRestore } from "../api/backup";
|
import { backupConfigure, backupNow, backupPhrase, backupRestore } from "../api/backup";
|
||||||
import { contactUpdate, contactsList } from "../api/contacts";
|
import { contactUpdate, contactsList } from "../api/contacts";
|
||||||
import { imapServers } from "../api/imap";
|
import { imapServers } from "../api/imap";
|
||||||
|
import { otpAutofillEnable, otpAutofillStatus, type OtpAutofillStatus } from "../api/otpAutofill";
|
||||||
import {
|
import {
|
||||||
askForNotifications,
|
askForNotifications,
|
||||||
notifyPermission,
|
notifyPermission,
|
||||||
@@ -41,6 +42,7 @@ import {
|
|||||||
REQUIRED_SCOPE,
|
REQUIRED_SCOPE,
|
||||||
SCOPES,
|
SCOPES,
|
||||||
isDesktop,
|
isDesktop,
|
||||||
|
isMacDesktop,
|
||||||
isTauri,
|
isTauri,
|
||||||
type Account,
|
type Account,
|
||||||
type BackupSettings,
|
type BackupSettings,
|
||||||
@@ -1401,6 +1403,20 @@ function PrivacySection() {
|
|||||||
const save = useSettings((s) => s.save);
|
const save = useSettings((s) => s.save);
|
||||||
const [allowed, setAllowed] = useState<ContactCard[]>([]);
|
const [allowed, setAllowed] = useState<ContactCard[]>([]);
|
||||||
const [phase, setPhase] = useState<"loading" | "idle" | "error">("loading");
|
const [phase, setPhase] = useState<"loading" | "idle" | "error">("loading");
|
||||||
|
const [otpStatus, setOtpStatus] = useState<OtpAutofillStatus | null>(null);
|
||||||
|
const [otpBusy, setOtpBusy] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
void otpAutofillStatus().then((status) => {
|
||||||
|
if (alive) setOtpStatus(status);
|
||||||
|
}).catch((error) => {
|
||||||
|
if (!alive) return;
|
||||||
|
setOtpStatus({ available: false, enabled: false });
|
||||||
|
notify(`Could not check Email OTP AutoFill: ${error}`);
|
||||||
|
});
|
||||||
|
return () => { alive = false; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
@@ -1420,6 +1436,25 @@ function PrivacySection() {
|
|||||||
|
|
||||||
if (!settings) return <h2 className="settings-title">Privacy</h2>;
|
if (!settings) return <h2 className="settings-title">Privacy</h2>;
|
||||||
|
|
||||||
|
const changeOtpAutofill = async (enabled: boolean) => {
|
||||||
|
setOtpBusy(true);
|
||||||
|
try {
|
||||||
|
if (enabled && !otpStatus?.enabled) {
|
||||||
|
const activated = await otpAutofillEnable();
|
||||||
|
if (!activated) {
|
||||||
|
notify("Email OTP AutoFill was not enabled in macOS.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setOtpStatus({ available: true, enabled: true });
|
||||||
|
}
|
||||||
|
await save({ otpAutofillEnabled: enabled });
|
||||||
|
} catch (error) {
|
||||||
|
notify(`Could not enable Email OTP AutoFill: ${error}`);
|
||||||
|
} finally {
|
||||||
|
setOtpBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const forget = (card: ContactCard) => {
|
const forget = (card: ContactCard) => {
|
||||||
setAllowed((were) => were.filter((one) => one !== card));
|
setAllowed((were) => were.filter((one) => one !== card));
|
||||||
void contactUpdate(card.accountId, card.person.address, { allowRemoteImages: false }).catch(
|
void contactUpdate(card.accountId, card.person.address, { allowRemoteImages: false }).catch(
|
||||||
@@ -1438,6 +1473,26 @@ function PrivacySection() {
|
|||||||
decided somewhere else.
|
decided somewhere else.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div className="set-field">
|
||||||
|
<Toggle checked={settings.otpAutofillEnabled && (otpStatus?.enabled ?? false)}
|
||||||
|
label="Email OTP AutoFill"
|
||||||
|
disabled={!otpStatus?.available || otpBusy}
|
||||||
|
note="Suggest recent email verification codes in compatible website and app fields through macOS AutoFill. Codes expire after three minutes. Detection stays on this device."
|
||||||
|
onChange={(enabled) => void changeOtpAutofill(enabled)} />
|
||||||
|
<p className="settings-note">
|
||||||
|
{otpStatus?.available
|
||||||
|
? "Enable Margin Mail under macOS System Settings → General → AutoFill & Passwords when prompted. Keep Margin Mail running and unlocked to receive codes."
|
||||||
|
: "Requires macOS 15 or later and the installed Margin Mail AutoFill extension."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="set-field">
|
||||||
|
<Toggle checked={settings.appLockEnabled} label="Require authentication when opening"
|
||||||
|
disabled={!isMacDesktop}
|
||||||
|
note="On macOS, unlock with Touch ID or your Mac login password when starting the app."
|
||||||
|
onChange={(appLockEnabled) => void save({ appLockEnabled })} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<SettingRow
|
<SettingRow
|
||||||
label="Remote images"
|
label="Remote images"
|
||||||
note="Loading a remote image tells the server that hosts it that you opened the message, from your IP address, at that moment. Nothing else in the app reveals that."
|
note="Loading a remote image tells the server that hosts it that you opened the message, from your IP address, at that moment. Nothing else in the app reveals that."
|
||||||
@@ -1709,6 +1764,24 @@ function WritingSection() {
|
|||||||
What happens when you send, and what goes out under your name.
|
What happens when you send, and what goes out under your name.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div className="set-field">
|
||||||
|
<Toggle checked={settings.spellingEnabled} label="Check spelling"
|
||||||
|
note="Use local spelling suggestions while writing."
|
||||||
|
onChange={(spellingEnabled) => void save({ spellingEnabled })} />
|
||||||
|
</div>
|
||||||
|
<div className="set-field">
|
||||||
|
<Toggle checked={settings.grammarEnabled} label="Check grammar"
|
||||||
|
disabled={!isMacDesktop}
|
||||||
|
note="Use macOS grammar suggestions while writing."
|
||||||
|
onChange={(grammarEnabled) => void save({ grammarEnabled })} />
|
||||||
|
</div>
|
||||||
|
<div className="set-field">
|
||||||
|
<Toggle checked={settings.writingToolsEnabled} label="Apple Writing Tools"
|
||||||
|
disabled={!isMacDesktop}
|
||||||
|
note="Show Proofread and Rewrite actions. Requires Apple Intelligence enabled on a supported Mac."
|
||||||
|
onChange={(writingToolsEnabled) => void save({ writingToolsEnabled })} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<SettingRow
|
<SettingRow
|
||||||
label="Undo delay"
|
label="Undo delay"
|
||||||
note="How long a send waits with its toast up before it actually goes. z takes it back."
|
note="How long a send waits with its toast up before it actually goes. z takes it back."
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
.app-lock {
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-lock-content {
|
||||||
|
padding: 24px;
|
||||||
|
max-width: 30em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-lock-title {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: var(--t-5);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-lock-description {
|
||||||
|
margin: 12px 0 24px;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-lock-error {
|
||||||
|
margin: 0 0 20px;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
+22
-1
@@ -78,6 +78,27 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor:not([data-empty]) .editor-placeholder {
|
.editor-content:not([data-empty]) .editor-placeholder {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.editor-content { position: relative; min-height: 90px; }
|
||||||
|
.editor-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 2px; margin: 0 0 12px; padding: 0 0 8px; border-bottom: 1px solid var(--line); }
|
||||||
|
.editor-tool { display: inline-flex; align-items: center; justify-content: center; gap: 4px; min-width: 28px; height: 28px; padding: 4px; border-radius: var(--r-sm); color: var(--ink-soft); font-family: var(--font-ui); font-size: var(--t-3); }
|
||||||
|
.editor-tool:hover, .editor-tool[aria-pressed="true"] { background: var(--accent-wash); color: var(--ink); }
|
||||||
|
.editor-tool:focus-visible { outline: 2px solid var(--ink-soft); outline-offset: 2px; }
|
||||||
|
.editor-tool:disabled { opacity: 0.35; cursor: default; }
|
||||||
|
.editor-tool-separator { width: 1px; height: 16px; background: var(--line); margin: 0 4px; }
|
||||||
|
.editor-link { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; padding: 8px; margin-bottom: 12px; border: 1px solid var(--line); border-radius: var(--r-sm); }
|
||||||
|
.editor-link input { flex: 1; min-width: 120px; outline: none; font-size: var(--t-3); }
|
||||||
|
.editor-link button, .editor-proof-issue button { padding: 4px 6px; border-radius: var(--r-sm); font-size: var(--t-2); color: var(--ink); }
|
||||||
|
.editor-link button:hover, .editor-proof-issue button:hover { background: var(--accent-wash); }
|
||||||
|
.editor-link [role="alert"] { flex-basis: 100%; color: var(--ink-soft); font-size: var(--t-2); }
|
||||||
|
.editor-proof-spelling { text-decoration: underline wavy #c96c62; text-underline-offset: 3px; }
|
||||||
|
.editor-proof-grammar { text-decoration: underline wavy #699cc6; text-underline-offset: 3px; }
|
||||||
|
.editor-proof-panel { max-height: 180px; overflow-y: auto; padding: 8px; margin-bottom: 12px; border: 1px solid var(--line); border-radius: var(--r-sm); color: var(--ink-soft); font-size: var(--t-2); }
|
||||||
|
.editor-proof-panel p { margin: 0; }
|
||||||
|
.editor-proof-issue { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; padding: 4px 0; }
|
||||||
|
.editor-proof-issue > span { flex-basis: 100%; }
|
||||||
|
.editor-proof-word { font-weight: 600; }
|
||||||
|
.editor-writing-error { margin: 0 0 12px; color: var(--ink-soft); font-size: var(--t-2); }
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export function signatureHtml(signature: string): string {
|
||||||
|
if (/<\/?[a-z][^>]*>/i.test(signature)) return signature;
|
||||||
|
return signature.split("\n").map((line) =>
|
||||||
|
`<p>${line.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")}</p>`).join("");
|
||||||
|
}
|
||||||
+14
-16
@@ -14,6 +14,7 @@ import { useAccounts } from "./useAccounts";
|
|||||||
import { useMail } from "./useMail";
|
import { useMail } from "./useMail";
|
||||||
import { useSettings } from "./useSettings";
|
import { useSettings } from "./useSettings";
|
||||||
import { notify, useToast } from "./useToast";
|
import { notify, useToast } from "./useToast";
|
||||||
|
import { signatureHtml } from "../signature";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Everything being written, and the one send that has not gone yet.
|
* Everything being written, and the one send that has not gone yet.
|
||||||
@@ -195,7 +196,7 @@ function sendingAccount(): string | null {
|
|||||||
function signatureFor(accountId: string): string {
|
function signatureFor(accountId: string): string {
|
||||||
const settings = useSettings.getState().settings;
|
const settings = useSettings.getState().settings;
|
||||||
const signature = settings?.accounts.find((a) => a.accountId === accountId)?.signature ?? "";
|
const signature = settings?.accounts.find((a) => a.accountId === accountId)?.signature ?? "";
|
||||||
return signature ? `<p></p><p>${signature}</p>` : "<p></p>";
|
return signature ? `<p></p>${signatureHtml(signature)}` : "<p></p>";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The people a reply goes to, with you and the sender taken out of the extra ones. */
|
/** The people a reply goes to, with you and the sender taken out of the extra ones. */
|
||||||
@@ -224,6 +225,14 @@ const escapeHtml = (text: string): string =>
|
|||||||
|
|
||||||
const saveTimers: Record<ComposerAt, number> = { card: 0, reply: 0 };
|
const saveTimers: Record<ComposerAt, number> = { card: 0, reply: 0 };
|
||||||
const saveRequests: Partial<Record<ComposerAt, Promise<void>>> = {};
|
const saveRequests: Partial<Record<ComposerAt, Promise<void>>> = {};
|
||||||
|
const attachmentRequests: Record<ComposerAt, Set<Promise<void>>> = { card: new Set(), reply: new Set() };
|
||||||
|
|
||||||
|
export function storeAttachments(at: ComposerAt, work: Promise<void>): Promise<void> {
|
||||||
|
attachmentRequests[at].add(work);
|
||||||
|
const clear = () => attachmentRequests[at].delete(work);
|
||||||
|
void work.then(clear, clear);
|
||||||
|
return work;
|
||||||
|
}
|
||||||
|
|
||||||
function scheduleSave(at: ComposerAt): void {
|
function scheduleSave(at: ComposerAt): void {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
@@ -277,19 +286,6 @@ function startBeat(): void {
|
|||||||
beat = window.setInterval(tick, 1000);
|
beat = window.setInterval(tick, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The row this send just went out on, found in the outbox.
|
|
||||||
*
|
|
||||||
* `send` answers with an `Undo` and not with the `Outgoing` it queued, so the only handle on the
|
|
||||||
* queued message is the outbox itself. The one that is holding the longest is the one that was
|
|
||||||
* queued last, because every hold is the same length from the moment it was made.
|
|
||||||
*/
|
|
||||||
async function newestOutgoing(): Promise<string | null> {
|
|
||||||
const outbox = await outboxList();
|
|
||||||
if (outbox.length === 0) return null;
|
|
||||||
return outbox.reduce((latest, item) => (item.holdUntilMs > latest.holdUntilMs ? item : latest)).id;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useCompose = create<ComposeState>((set, get) => ({
|
export const useCompose = create<ComposeState>((set, get) => ({
|
||||||
drafts: [],
|
drafts: [],
|
||||||
loadDrafts: async () => {
|
loadDrafts: async () => {
|
||||||
@@ -366,6 +362,7 @@ export const useCompose = create<ComposeState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
closeCard: async () => {
|
closeCard: async () => {
|
||||||
|
try { await Promise.all(attachmentRequests.card); } catch { return; }
|
||||||
await get().save("card");
|
await get().save("card");
|
||||||
const card = get().card;
|
const card = get().card;
|
||||||
if (!card || card.phase === "error") return;
|
if (!card || card.phase === "error") return;
|
||||||
@@ -466,6 +463,7 @@ export const useCompose = create<ComposeState>((set, get) => ({
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
closeReply: async () => {
|
closeReply: async () => {
|
||||||
|
try { await Promise.all(attachmentRequests.reply); } catch { return; }
|
||||||
await get().save("reply");
|
await get().save("reply");
|
||||||
const reply = get().reply;
|
const reply = get().reply;
|
||||||
if (!reply || reply.phase === "error") return;
|
if (!reply || reply.phase === "error") return;
|
||||||
@@ -601,6 +599,7 @@ export const useCompose = create<ComposeState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
post: async (at, now) => {
|
post: async (at, now) => {
|
||||||
|
try { await Promise.all(attachmentRequests[at]); } catch { return; }
|
||||||
cancelSave(at);
|
cancelSave(at);
|
||||||
await saveRequests[at];
|
await saveRequests[at];
|
||||||
const composer = composerAt(get(), at);
|
const composer = composerAt(get(), at);
|
||||||
@@ -647,8 +646,7 @@ export const useCompose = create<ComposeState>((set, get) => ({
|
|||||||
// from here until the provider answers, and that can be twenty seconds.
|
// from here until the provider answers, and that can be twenty seconds.
|
||||||
notify(`Sending to ${named(draft.to[0])}`);
|
notify(`Sending to ${named(draft.to[0])}`);
|
||||||
try {
|
try {
|
||||||
const id = await newestOutgoing();
|
await sendNow(undo.token);
|
||||||
if (id) await sendNow(id);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
notify(`Could not skip the wait: ${e}`);
|
notify(`Could not skip the wait: ${e}`);
|
||||||
void useMail.getState().load();
|
void useMail.getState().load();
|
||||||
|
|||||||
Reference in new issue
Block a user