diff --git a/.package.resolved b/.package.resolved index 94879cab5..a9ab9753d 100644 --- a/.package.resolved +++ b/.package.resolved @@ -85,8 +85,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Infomaniak/ios-notifications", "state" : { - "revision" : "0da8aa11e60766acaa62e34c84a8049eeb6e933f", - "version" : "2.0.1" + "revision" : "4609e33800a11f9f7acd38df66d1fbaaba0e60a8", + "version" : "2.1.0" } }, { diff --git a/Mail/Helpers/RichTextEditor.swift b/Mail/Helpers/RichTextEditor.swift index 4bf068989..4ae867ae1 100644 --- a/Mail/Helpers/RichTextEditor.swift +++ b/Mail/Helpers/RichTextEditor.swift @@ -148,25 +148,10 @@ class MailEditorView: SQTextEditorView { config.userContentController.add(self, name: jsMessageName.rawValue) } - // inject css to html - if customCss == nil, - let cssURL = Bundle(for: SQTextEditorView.self).url(forResource: "editor", withExtension: "css"), - let css = try? String(contentsOf: cssURL, encoding: .utf8) { - customCss = css - } - - if let css = customCss { - let cssStyle = """ - javascript:(function() { - var parent = document.getElementsByTagName('head').item(0); - var style = document.createElement('style'); - style.type = 'text/css'; - style.innerHTML = window.atob('\(encodeStringTo64(fromString: css))'); - parent.appendChild(style)})() - """ - let cssScript = WKUserScript(source: cssStyle, injectionTime: .atDocumentEnd, forMainFrameOnly: false) - config.userContentController.addUserScript(cssScript) - } + let css = customCss ?? MessageWebViewUtils.generateCSS(for: .editor) + let cssStyle = "(() => { document.head.innerHTML += `\(css)`; })()" + let cssScript = WKUserScript(source: cssStyle, injectionTime: .atDocumentEnd, forMainFrameOnly: false) + config.userContentController.addUserScript(cssScript) let _webView = WKWebView(frame: .zero, configuration: config) _webView.translatesAutoresizingMaskIntoConstraints = false diff --git a/Mail/Views/Thread/MessageBodyView.swift b/Mail/Views/Thread/MessageBodyView.swift index 0876a0fed..0acf238eb 100644 --- a/Mail/Views/Thread/MessageBodyView.swift +++ b/Mail/Views/Thread/MessageBodyView.swift @@ -22,14 +22,11 @@ import RealmSwift import SwiftUI struct MessageBodyView: View { - @Binding var presentableBody: PresentableBody + @StateObject private var model = WebViewModel() - @State var model = WebViewModel() - @State private var webViewShortHeight: CGFloat = .zero - @State private var webViewCompleteHeight: CGFloat = .zero + @Binding var presentableBody: PresentableBody - @State private var showBlockQuote = false - @State private var contentLoading = true + let messageUid: String var body: some View { ZStack { @@ -40,33 +37,27 @@ struct MessageBodyView: View { .padding(.horizontal, 16) .onAppear { withAnimation { - contentLoading = false + model.contentLoading = false } } } else { - WebView( - model: $model, - shortHeight: $webViewShortHeight, - completeHeight: $webViewCompleteHeight, - loading: $contentLoading, - withQuote: $showBlockQuote - ) - .frame(minHeight: showBlockQuote ? webViewCompleteHeight : webViewShortHeight) - .onAppear { - loadBody() - } - .onChange(of: presentableBody) { _ in - loadBody() - } - .onChange(of: showBlockQuote) { _ in - loadBody() - } + WebView(model: model, messageUid: messageUid) + .frame(height: model.webViewHeight) + .onAppear { + loadBody() + } + .onChange(of: presentableBody) { _ in + loadBody() + } + .onChange(of: model.showBlockQuote) { _ in + loadBody() + } if presentableBody.quote != nil { - MailButton(label: showBlockQuote + MailButton(label: model.showBlockQuote ? MailResourcesStrings.Localizable.messageHideQuotedText : MailResourcesStrings.Localizable.messageShowQuotedText) { - showBlockQuote.toggle() + model.showBlockQuote.toggle() } .mailButtonStyle(.smallLink) .frame(maxWidth: .infinity, alignment: .leading) @@ -75,20 +66,21 @@ struct MessageBodyView: View { } } } - .opacity(contentLoading ? 0 : 1) - if contentLoading { + .opacity(model.contentLoading ? 0 : 1) + + if model.contentLoading { ShimmerView() } } } private func loadBody() { - model.loadHTMLString(value: showBlockQuote ? presentableBody.body?.value : presentableBody.compactBody) + model.loadHTMLString(value: model.showBlockQuote ? presentableBody.body?.value : presentableBody.compactBody) } } struct MessageBodyView_Previews: PreviewProvider { static var previews: some View { - MessageBodyView(presentableBody: .constant(PreviewHelper.samplePresentableBody)) + MessageBodyView(presentableBody: .constant(PreviewHelper.samplePresentableBody), messageUid: "message_uid") } } diff --git a/Mail/Views/Thread/MessageView.swift b/Mail/Views/Thread/MessageView.swift index c19fc21ac..9e2fda985 100644 --- a/Mail/Views/Thread/MessageView.swift +++ b/Mail/Views/Thread/MessageView.swift @@ -51,11 +51,11 @@ struct MessageView: View { .padding(.horizontal, 16) if isMessageExpanded { - if !message.attachments.filter { $0.disposition == .attachment || $0.contentId == nil }.isEmpty { + if !message.attachments.filter({ $0.disposition == .attachment || $0.contentId == nil }).isEmpty { AttachmentsView(message: message) .padding(.top, 24) } - MessageBodyView(presentableBody: $presentableBody) + MessageBodyView(presentableBody: $presentableBody, messageUid: message.uid) .padding(.top, 16) } } diff --git a/Mail/Views/Thread/WebView.swift b/Mail/Views/Thread/WebView.swift new file mode 100644 index 000000000..5a5708037 --- /dev/null +++ b/Mail/Views/Thread/WebView.swift @@ -0,0 +1,90 @@ +/* + Infomaniak Mail - iOS App + Copyright (C) 2022 Infomaniak Network SA + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +import SwiftUI +import WebKit + +struct WebView: UIViewRepresentable { + @ObservedObject var model: WebViewModel + + let messageUid: String + + private var webView: WKWebView { + return model.webView + } + + class Coordinator: NSObject, WKNavigationDelegate { + var parent: WebView + + init(_ parent: WebView) { + self.parent = parent + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + Task { @MainActor in + try await webView.evaluateJavaScript("listenToSizeChanges()") + + // Fix CSS properties and adapt the mail to the screen size + let readyState = try await webView.evaluateJavaScript("document.readyState") as? String + guard readyState == "complete" else { return } + + _ = try await webView.evaluateJavaScript("removeAllProperties()") + _ = try await webView.evaluateJavaScript("normalizeMessageWidth(\(webView.frame.width), '\(parent.messageUid)')") + } + } + + func webView( + _ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void + ) { + if navigationAction.navigationType == .linkActivated { + if let url = navigationAction.request.url { + decisionHandler(.cancel) + UIApplication.shared.open(url) + } + } else { + decisionHandler(.allow) + } + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + func makeUIView(context: Context) -> WKWebView { + webView.navigationDelegate = context.coordinator + webView.scrollView.bounces = false + webView.scrollView.bouncesZoom = false + webView.scrollView.showsVerticalScrollIndicator = false + webView.scrollView.showsHorizontalScrollIndicator = true + webView.scrollView.alwaysBounceVertical = false + webView.scrollView.alwaysBounceHorizontal = false + #if DEBUG + if #available(iOS 16.4, *) { + webView.isInspectable = true + } + #endif + return webView + } + + func updateUIView(_ uiView: WKWebView, context: Context) { + // needed for UIViewRepresentable + } +} diff --git a/Mail/Views/Thread/WebViewModel.swift b/Mail/Views/Thread/WebViewModel.swift index 0d981ce48..cfeb42978 100644 --- a/Mail/Views/Thread/WebViewModel.swift +++ b/Mail/Views/Thread/WebViewModel.swift @@ -18,144 +18,155 @@ import CocoaLumberjackSwift import MailCore +import Sentry import SwiftSoup import SwiftUI import WebKit -struct WebView: UIViewRepresentable { - typealias UIViewType = WKWebView +final class WebViewModel: NSObject, ObservableObject { + @Published var webViewHeight: CGFloat = .zero - @Binding var model: WebViewModel - @Binding var shortHeight: CGFloat - @Binding var completeHeight: CGFloat - @Binding var loading: Bool - @Binding var withQuote: Bool + @Published var showBlockQuote = false + @Published var contentLoading = true - var webView: WKWebView { - return model.webView + let webView: WKWebView + + private let style: String = MessageWebViewUtils.generateCSS(for: .message) + + override init() { + webView = WKWebView() + + super.init() + + setUpWebViewConfiguration() + loadScripts(configuration: webView.configuration) } - class Coordinator: NSObject, WKNavigationDelegate { - var parent: WebView + func loadHTMLString(value: String?) { + guard let rawHtml = value else { return } - init(_ parent: WebView) { - self.parent = parent - } + do { + guard let safeDocument = MessageWebViewUtils.cleanHtmlContent(rawHtml: rawHtml) else { return } + + try updateHeadContent(of: safeDocument) + try wrapBody(document: safeDocument, inID: Constants.divWrapperId) - private func updateHeight(height: CGFloat) { - if !parent.withQuote { - if parent.shortHeight < height { - parent.shortHeight = height - parent.completeHeight = height - withAnimation { - parent.loading = false - } - } - } else if parent.completeHeight < height { - parent.completeHeight = height - } + let finalHtml = try safeDocument.outerHtml() + webView.loadHTMLString(finalHtml, baseURL: nil) + } catch { + DDLogError("An error occurred while parsing body \(error)") } + } - func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - Task { @MainActor in - let readyState = try await webView.evaluateJavaScript("document.readyState") as? String - guard readyState == "complete" else { return } + private func setUpWebViewConfiguration() { + webView.configuration.dataDetectorTypes = .all + webView.configuration.defaultWebpagePreferences.allowsContentJavaScript = false + webView.configuration.setURLSchemeHandler(URLSchemeHandler(), forURLScheme: URLSchemeHandler.scheme) - let scrollHeight = try await webView.evaluateJavaScript("document.documentElement.scrollHeight") as? CGFloat - guard let scrollHeight else { return } - updateHeight(height: scrollHeight) - } - } + webView.configuration.userContentController.add(self, name: JavaScriptMessageTopic.log.rawValue) + webView.configuration.userContentController.add(self, name: JavaScriptMessageTopic.sizeChanged.rawValue) + webView.configuration.userContentController.add(self, name: JavaScriptMessageTopic.overScroll.rawValue) + webView.configuration.userContentController.add(self, name: JavaScriptMessageTopic.error.rawValue) + } + + private func loadScripts(configuration: WKWebViewConfiguration) { + var scripts = ["javaScriptBridge", "fixEmailStyle", "sizeHandler"] + #if DEBUG + scripts.insert("captureLog", at: 0) + #endif - func webView( - _ webView: WKWebView, - decidePolicyFor navigationAction: WKNavigationAction, - decisionHandler: @escaping (WKNavigationActionPolicy) -> Void - ) { - if navigationAction.navigationType == .linkActivated { - if let url = navigationAction.request.url { - decisionHandler(.cancel) - UIApplication.shared.open(url) - } - } else { - decisionHandler(.allow) - } + for script in scripts { + configuration.userContentController + .addUserScript(named: script, injectionTime: .atDocumentStart, forMainFrameOnly: true) } - } - func makeCoordinator() -> Coordinator { - Coordinator(self) + if let mungeScript = Constants.mungeEmailScript { + configuration.userContentController + .addUserScript(WKUserScript(source: mungeScript, injectionTime: .atDocumentStart, forMainFrameOnly: true)) + } } - func makeUIView(context: Context) -> WKWebView { - webView.navigationDelegate = context.coordinator - webView.scrollView.bounces = false - webView.scrollView.bouncesZoom = false - webView.scrollView.showsVerticalScrollIndicator = false - webView.scrollView.showsHorizontalScrollIndicator = true - webView.scrollView.alwaysBounceVertical = false - webView.scrollView.alwaysBounceHorizontal = false - return webView + private func updateHeadContent(of document: Document) throws { + let head = document.head() + if let viewport = try head?.select("meta[name=\"viewport\"]"), !viewport.isEmpty() { + try viewport.attr("content", Constants.viewportContent) + } else { + try head?.append("") + } + try head?.append(style) } - func updateUIView(_ uiView: WKWebView, context: Context) { - // needed for UIViewRepresentable + private func wrapBody(document: Document, inID id: String) throws { + if let bodyContent = document.body()?.childNodesCopy() { + document.body()?.empty() + try document.body()? + .appendElement("div") + .attr("id", id) + .insertChildren(-1, bodyContent) + } } } -class WebViewModel { - let webView: WKWebView - var viewport: String { - return "" +extension WebViewModel: WKScriptMessageHandler { + private enum JavaScriptMessageTopic: String { + case log, sizeChanged, overScroll, error } - var style: String { - return "" + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + guard let event = JavaScriptMessageTopic(rawValue: message.name) else { return } + switch event { + case .log: + print(message.body) + case .sizeChanged: + updateWebViewHeight(message) + case .overScroll: + sendOverScrollMessage(message) + case .error: + sendJavaScriptError(message) + } } - init() { - let configuration = WKWebViewConfiguration() - configuration.dataDetectorTypes = .all - configuration.defaultWebpagePreferences.allowsContentJavaScript = false - configuration.setURLSchemeHandler(URLSchemeHandler(), forURLScheme: URLSchemeHandler.scheme) - webView = WKWebView(frame: .zero, configuration: configuration) + private func updateWebViewHeight(_ message: WKScriptMessage) { + guard let data = message.body as? [String: CGFloat], let height = data["height"] else { return } + + // On some messages, the size infinitely increases by 1px ? + // Having a threshold avoids this problem + if Int(abs(webViewHeight - height)) > Constants.sizeChangeThreshold { + contentLoading = false + webViewHeight = height + } } - func loadHTMLString(value: String?) { - guard let rawHtml = value else { - return + private func sendOverScrollMessage(_ message: WKScriptMessage) { + guard let data = message.body as? [String: String] else { return } + + SentrySDK.capture(message: "After zooming the mail it can still scroll.") { scope in + scope.setTags(["messageUid": data["messageId"] ?? ""]) + scope.setExtras([ + "clientWidth": data["clientWidth"], + "scrollWidth": data["scrollWidth"] + ]) } + } - do { - guard let safeHtml = MessageBodyUtils.cleanHtmlContent(rawHtml: rawHtml) else { return } - let parsedHtml = try SwiftSoup.parse(safeHtml) - - let head: Element - if let existingHead = parsedHtml.head() { - head = existingHead - } else { - head = try parsedHtml.appendElement("head") - } - - let allImages = try parsedHtml.select("img[width]").array() - let maxWidth = webView.frame.width - for image in allImages { - if let widthString = image.getAttributes()?.get(key: "width"), - let width = Double(widthString), - width > maxWidth { - try image.attr("width", "\(maxWidth)") - try image.attr("height", "auto") - } - } - - try head.append(viewport) - try head.append(style) - - let finalHtml = try parsedHtml.html() + private func sendJavaScriptError(_ message: WKScriptMessage) { + guard let data = message.body as? [String: String] else { return } - webView.loadHTMLString(finalHtml, baseURL: nil) - } catch { - DDLogError("An error occurred while parsing body \(error)") + SentrySDK.capture(message: "JavaScript returned an error when displaying an email.") { scope in + scope.setTags(["messageUid": data["messageId"] ?? ""]) + scope.setExtras([ + "errorName": data["errorName"], + "errorMessage": data["errorMessage"], + "errorStack": data["errorStack"] + ]) + } + } +} + +extension WKUserContentController { + func addUserScript(named filename: String, injectionTime: WKUserScriptInjectionTime, forMainFrameOnly: Bool) { + if let script = Bundle.main.load(filename: filename, withExtension: "js") { + addUserScript(WKUserScript(source: script, injectionTime: injectionTime, forMainFrameOnly: forMainFrameOnly)) } } } diff --git a/MailCore/Models/Draft.swift b/MailCore/Models/Draft.swift index c3b2efb30..14ba026dc 100644 --- a/MailCore/Models/Draft.swift +++ b/MailCore/Models/Draft.swift @@ -190,7 +190,7 @@ public class Draft: Object, Decodable, Identifiable, Encodable { unsafeQuote = Constants.forwardQuote(message: message) } - let quote = MessageBodyUtils.cleanHtmlContent(rawHtml: unsafeQuote) ?? "" + let quote = (try? MessageWebViewUtils.cleanHtmlContent(rawHtml: unsafeQuote)?.outerHtml()) ?? "" return "

" + quote } diff --git a/MailCore/Utils/Bundle+Extension.swift b/MailCore/Utils/Bundle+Extension.swift new file mode 100644 index 000000000..0d9d365fc --- /dev/null +++ b/MailCore/Utils/Bundle+Extension.swift @@ -0,0 +1,32 @@ +/* + Infomaniak Mail - iOS App + Copyright (C) 2022 Infomaniak Network SA + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +import Foundation + +public extension Bundle { + func load(filename: String, withExtension fileExtension: String) -> String? { + guard let url = Bundle.main.url(forResource: filename, withExtension: fileExtension), + let document = try? String(contentsOf: url) else { return nil } + return document + } + + func loadCSS(filename: String) -> String? { + guard let css = load(filename: filename, withExtension: "css") else { return nil } + return css.replacingOccurrences(of: "\n", with: "") + } +} diff --git a/MailCore/Utils/Color+Extension.swift b/MailCore/Utils/Color+Extension.swift index cd0f80139..587df169d 100644 --- a/MailCore/Utils/Color+Extension.swift +++ b/MailCore/Utils/Color+Extension.swift @@ -43,4 +43,14 @@ public extension Color { opacity: Double(a) / 255 ) } + + var hexRepresentation: String { + let uiColor = UIColor(self) + guard let components = uiColor.cgColor.components else { return "#FFFFFF" } + + let red = Int(components[0] * 255) + let green = Int(components[1] * 255) + let blue = Int(components[2] * 255) + return String(format: "#%02X%02X%02X", red, green, blue) + } } diff --git a/MailCore/Utils/Constants.swift b/MailCore/Utils/Constants.swift index 73d1f15e9..ed3e0212c 100644 --- a/MailCore/Utils/Constants.swift +++ b/MailCore/Utils/Constants.swift @@ -65,8 +65,13 @@ public enum Constants { try! NSRegularExpression(pattern: ">\\s*<|>?\\s+ Bool { return emailPredicate.evaluate(with: mail.lowercased()) diff --git a/MailCore/Utils/MessageBodyUtils.swift b/MailCore/Utils/MessageBodyUtils.swift index 8c53787f0..156923a1f 100644 --- a/MailCore/Utils/MessageBodyUtils.swift +++ b/MailCore/Utils/MessageBodyUtils.swift @@ -67,30 +67,6 @@ public enum MessageBodyUtils { return nil } - public static func cleanHtmlContent(rawHtml: String) -> String? { - do { - let dirtyDocument = try SwiftSoup.parse(rawHtml) - let cleanedDocument = try SwiftSoup.Cleaner(headWhitelist: .headWhitelist, bodyWhitelist: .extendedBodyWhitelist) - .clean(dirtyDocument) - - // We need to remove the tag - let metaRefreshTags = try cleanedDocument.select("meta[http-equiv='refresh']") - for metaRefreshTag in metaRefreshTags { - try metaRefreshTag.parent()?.removeChild(metaRefreshTag) - } - - // If `` has a style attribute, keep it - if let bodyStyleAttribute = try dirtyDocument.body()?.attr("style") { - try cleanedDocument.body()?.attr("style", bodyStyleAttribute) - } - - return try cleanedDocument.outerHtml() - } catch { - DDLogError("An error occurred while parsing body \(error)") - return nil - } - } - private static func findAndRemoveLastParentBlockQuote(htmlDocumentWithoutQuote: Document) throws -> Element? { let element = try selectLastParentBlockQuote(document: htmlDocumentWithoutQuote) try element?.remove() diff --git a/MailCore/Utils/MessageWebViewUtils.swift b/MailCore/Utils/MessageWebViewUtils.swift new file mode 100644 index 000000000..017b2c3c6 --- /dev/null +++ b/MailCore/Utils/MessageWebViewUtils.swift @@ -0,0 +1,69 @@ +/* + Infomaniak Mail - iOS App + Copyright (C) 2022 Infomaniak Network SA + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +import CocoaLumberjackSwift +import Foundation +import SwiftSoup + +public enum MessageWebViewUtils { + public enum WebViewTarget { + case message, editor + } + + public static func generateCSS(for target: WebViewTarget) -> String { + var resources = "" + + if let style = Bundle.main.loadCSS(filename: "style") { + let variables = """ + :root { + --kmail-primary-color: \(UserDefaults.shared.accentColor.primary.swiftUIColor.hexRepresentation); + } + """ + resources += "".replacingOccurrences(of: "\n", with: "") + } + + if let fixDisplayCSS = Bundle.main.loadCSS(filename: "improveRendering") { + resources += "" + } + + if target == .editor, let editorCSS = Bundle.main.loadCSS(filename: "editor") { + resources += "" + } + + return resources + } + + public static func cleanHtmlContent(rawHtml: String) -> Document? { + do { + let dirtyDocument = try SwiftSoup.parse(rawHtml) + let cleanedDocument = try SwiftSoup.Cleaner(headWhitelist: .headWhitelist, bodyWhitelist: .extendedBodyWhitelist) + .clean(dirtyDocument) + + // We need to remove the tag + let metaRefreshTags = try cleanedDocument.select("meta[http-equiv='refresh']") + for metaRefreshTag in metaRefreshTags { + try metaRefreshTag.parent()?.removeChild(metaRefreshTag) + } + + return cleanedDocument + } catch { + DDLogError("An error occurred while parsing body \(error)") + return nil + } + } +} diff --git a/MailCore/Utils/Whitelist+Extension.swift b/MailCore/Utils/Whitelist+Extension.swift index 44dfd6928..0f1048bc6 100644 --- a/MailCore/Utils/Whitelist+Extension.swift +++ b/MailCore/Utils/Whitelist+Extension.swift @@ -40,10 +40,47 @@ extension Whitelist { do { let customWhitelist = try Whitelist.relaxed() try customWhitelist - .addTags("center", "hr", "style") - .addAttributes(":all", "align", "bgcolor", "border", "class", "dir", "height", "id", "style", "width") - .addAttributes("td", "valign") - .addProtocols("img", "src", "cid", "data") + .addTags( + "area", + "button", + "center", + "del", + "font", + "hr", + "ins", + "kbd", + "map", + "title", + "tt", + "samp", + "style", + "var" + ) + .addAttributes(":all", "class", "dir", "id", "style") + .addAttributes("a", "name") + // Allow all URI schemes in links. Removing all protocols makes the list of protocols empty which means allow all + // protocols + .removeProtocols("a", "href", "ftp", "http", "https", "mailto") + .addAttributes("area", "alt", "coords", "href", "shape") + .addProtocols("area", "href", "http", "https") + .addAttributes("body", "lang", "alink", "background", "bgcolor", "link", "text", "vlink") + .addAttributes("div", "align") + .addAttributes("font", "color", "face", "size") + .addAttributes("img", "usemap") + .addProtocols("img", "src", "cid", "data", "http", "https") + .addAttributes("map", "name") + .addAttributes("table", "align", "background", "bgcolor", "border", "cellpadding", "cellspacing", "width") + .addAttributes("tr", "align", "background", "bgcolor", "valign") + .addAttributes( + "th", + "align", "background", "bgcolor", "colspan", "headers", "height", "nowrap", "rowspan", "scope", + "sorted", "valign", "width" + ) + .addAttributes( + "td", + "align", "background", "bgcolor", "colspan", "headers", "height", "nowrap", "rowspan", "scope", + "valign", "width" + ) return customWhitelist } catch { diff --git a/MailResources/editor.css b/MailResources/css/editor.css similarity index 72% rename from MailResources/editor.css rename to MailResources/css/editor.css index 5ca62eb62..fd5dd6ceb 100644 --- a/MailResources/editor.css +++ b/MailResources/css/editor.css @@ -1,11 +1,13 @@ -@import "style.css"; - :root { color-scheme: light dark; } -html { - padding: 16px; +body { + margin: 0; +} + +#editor { + margin: 16px; } @media (prefers-color-scheme: dark) { diff --git a/MailResources/css/improveRendering.css b/MailResources/css/improveRendering.css new file mode 100644 index 000000000..45b92fd55 --- /dev/null +++ b/MailResources/css/improveRendering.css @@ -0,0 +1,18 @@ +body { + overflow-wrap: break-word; +} + +table.munged { + width: auto !important; + table-layout: auto !important; +} + +td.munged { + width: auto !important; + white-space: normal !important; +} + +pre { + word-wrap: break-word; + white-space: pre-wrap; +} diff --git a/MailResources/css/style.css b/MailResources/css/style.css new file mode 100644 index 000000000..9ffcb4ff4 --- /dev/null +++ b/MailResources/css/style.css @@ -0,0 +1,25 @@ +html { + margin: 0; + padding: 0; +} + +body { + font-family: -apple-system, Helvetica,Arial, sans-serif; + + padding: 0; + margin: 16px; + + width: auto !important; +} + +blockquote { + padding: 0.2em 1.2em !important; + margin: 0 !important; + border: 3px solid var(--kmail-primary-color) !important; + border-width: 0 0 0 2px !important; +} + +blockquote blockquote blockquote blockquote { + padding: 0 !important; + border: none !important; +} diff --git a/MailResources/js/captureLog.js b/MailResources/js/captureLog.js new file mode 100644 index 000000000..b5ddfecd8 --- /dev/null +++ b/MailResources/js/captureLog.js @@ -0,0 +1,24 @@ +/* + Infomaniak Mail - iOS App + Copyright (C) 2022 Infomaniak Network SA + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +function captureLog(message) { + window.webkit.messageHandlers.log.postMessage(message); +} + +window.console.log = captureLog; +window.console.info = captureLog; diff --git a/MailResources/js/fixEmailStyle.js b/MailResources/js/fixEmailStyle.js new file mode 100644 index 000000000..f5b56cade --- /dev/null +++ b/MailResources/js/fixEmailStyle.js @@ -0,0 +1,76 @@ +/* + Infomaniak Mail - iOS App + Copyright (C) 2022 Infomaniak Network SA + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +function removeAllProperties() { + const properties = [ + { name: 'position', values: ['absolute'] }, + { name: '-webkit-text-size-adjust', values: [] }, + { name: 'height', values: ['100%'] } + ]; + removeCSSProperty(properties); + return true; +} + +function removeCSSProperty(properties) { + removeFromInlineStyle(properties); + removeFromStylesheets(properties); +} + +function removeFromInlineStyle(properties) { + for (const property of properties) { + const elementsWithInlineStyle = document.querySelectorAll(`[style*=${property.name}]`); + for (const element of elementsWithInlineStyle) { + const propertyValue = element.style[property.name]; + if (shouldRemovePropertyForGivenValue(property, propertyValue)) { + element.style[property.name] = null; + console.info(`[FIX_EMAIL_STYLE] Remove property ${property.name} from inline style.`); + } + } + } +} + +function removeFromStylesheets(properties) { + for (let i = 0; i < document.styleSheets.length; i++) { + const styleSheet = document.styleSheets[i]; + try { + removePropertiesForAllCSSRules(properties, styleSheet); + } catch (error) { + // The stylesheet cannot be modified + } + } +} + +function removePropertiesForAllCSSRules(properties, styleSheet) { + for (let j = 0; j < styleSheet.cssRules.length; j++) { + for (const property of properties) { + if (!styleSheet.cssRules[j].style) { continue; } + + const propertyValue = styleSheet.cssRules[j].style[property.name]; + if (shouldRemovePropertyForGivenValue(property, propertyValue)) { + const removedValue = styleSheet.cssRules[j].style?.removeProperty(property.name); + if (removedValue) { + console.info(`[FIX_EMAIL_STYLE] Remove property ${property.name} from style tag.`); + } + } + } + } +} + +function shouldRemovePropertyForGivenValue(property, value) { + return property.values.length === 0 || property.values.includes(value.toLowerCase().trim()) +} diff --git a/MailResources/js/javaScriptBridge.js b/MailResources/js/javaScriptBridge.js new file mode 100644 index 000000000..9fb6a99d6 --- /dev/null +++ b/MailResources/js/javaScriptBridge.js @@ -0,0 +1,30 @@ +/* + Infomaniak Mail - iOS App + Copyright (C) 2022 Infomaniak Network SA + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +function reportOverScroll(clientWidth, scrollWidth, messageId) { + window.webkit.messageHandlers.overScroll.postMessage({ clientWidth, scrollWidth, messageId }); +} + +function reportError(error, messageId) { + window.webkit.messageHandlers.error.postMessage({ + errorName: error.name, + errorMessage: error.message, + errorStack: error.stack, + messageId + }); +} diff --git a/MailResources/js/mungeEmail.js b/MailResources/js/mungeEmail.js new file mode 100644 index 000000000..a977042a9 --- /dev/null +++ b/MailResources/js/mungeEmail.js @@ -0,0 +1,327 @@ +/* + Infomaniak Mail - iOS App + Copyright (C) 2022 Infomaniak Network SA + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +// MESSAGE_SELECTOR = "#kmail-message-content"; +const PREFERENCES = { + normalizeMessageWidths: true, + mungeImages: true, + mungeTables: true, + minimumEffectiveRatio: 0.7 +}; + +// Functions + +/** + * Normalize the width of the mail displayed + * @param webViewWidth Width of the webview + * @param messageUid Id of the displayed mail + */ +function normalizeMessageWidth(webViewWidth, messageUid) { + // We want to report any thrown error that our script may encounter + try { + normalizeElementWidths(document.querySelectorAll(MESSAGE_SELECTOR), webViewWidth, messageUid); + } catch (error) { + reportError(error, messageUid); + } + return true; +} + +/** + * Normalizes the width of elements supplied to the document body's overall width. + * Narrower elements are zoomed in, and wider elements are zoomed out. + * This method is idempotent. + * @param elements DOM elements to normalize + * @param webViewWidth Width of the webview + * @param messageUid Id of the displayed mail + */ +function normalizeElementWidths(elements, webViewWidth, messageUid) { + const documentWidth = document.body.offsetWidth; + logInfo(`Starts to normalize elements. Document width: ${documentWidth}. WebView width: ${webViewWidth}.`); + + for (const element of elements) { + logInfo(`Current element: ${elementDebugName(element)}.`); + + // Reset any existing normalization + const originalZoom = element.style.zoom; + if (originalZoom) { + element.style.zoom = 1; + logInfo(`Initial zoom reset to 1. Old zoom: ${originalZoom}.`); + } + + const originalWidth = element.style.width; + element.style.width = `${webViewWidth}px`; + transformContent(element, webViewWidth, element.scrollWidth); + + if (PREFERENCES.normalizeMessageWidths) { + const newZoom = documentWidth / element.scrollWidth; + logInfo(`Zoom updated: documentWidth / element.scrollWidth -> ${documentWidth} / ${element.scrollWidth} = ${newZoom}.`); + element.style.zoom = newZoom; + } + + element.style.width = originalWidth; + + if (document.documentElement.scrollWidth > document.documentElement.clientWidth) { + logInfo(`After zooming the mail it can still scroll: found clientWidth / scrollWidth -> ${document.documentElement.clientWidth} / ${document.documentElement.scrollWidth}`); + reportOverScroll(document.documentElement.clientWidth, document.documentElement.scrollWidth, messageUid); + } + } +} + +/** + * Transform the content of a DOM element to munge its children if they are too wide + * @param element DOM element to inspect + * @param documentWidth Width of the overall document + * @param elementWidth Element width before any action is done + */ +function transformContent(element, documentWidth, elementWidth) { + if (elementWidth <= documentWidth) { + logInfo(`Element doesn't need to be transformed. Current size: ${elementWidth}, DocumentWidth: ${documentWidth}.`); + return; + } + logInfo(`Element will be transformed.`); + + let newWidth = elementWidth; + let isTransformationDone = false; + /** Format of entries : { function: fn, object: object, arguments: [list of arguments] } */ + let actionsLog = []; + + // Try munging all divs or textareas with inline styles where the width + // is wider than `documentWidth`, and change it to be a max-width. + if (PREFERENCES.normalizeMessageWidths) { + const nodes = element.querySelectorAll('div[style], textarea[style]'); + const areNodesTransformed = transformBlockElements(nodes, documentWidth, actionsLog); + if (areNodesTransformed) { + newWidth = element.scrollWidth; + logTransformation('munge div[style] and textarea[style]', element, elementWidth, newWidth, documentWidth); + if (newWidth <= documentWidth) { + isTransformationDone = true; + logInfo('Munging div[style] and textarea[style] is enough.'); + } + } + } + + if (!isTransformationDone && PREFERENCES.mungeImages) { + // OK, that wasn't enough. Find images with widths and override their widths. + const images = element.querySelectorAll('img'); + const areImagesTransformed = transformImages(images, documentWidth, actionsLog); + if (areImagesTransformed) { + newWidth = element.scrollWidth; + logTransformation('munge img', element, elementWidth, newWidth, documentWidth); + if (newWidth <= documentWidth) { + isTransformationDone = true; + logInfo('Munging img is enough.'); + } + } + } + + if (!isTransformationDone && PREFERENCES.mungeTables) { + // OK, that wasn't enough. Find tables with widths and override their widths. + // Also ensure that any use of 'table-layout: fixed' is negated, since using + // that with 'width: auto' causes erratic table width. + const tables = element.querySelectorAll('table'); + const areTablesTransformed = addClassToElements(tables, shouldMungeTable, 'munged', actionsLog); + if (areTablesTransformed) { + newWidth = element.scrollWidth; + logTransformation('munge table', element, elementWidth, newWidth, documentWidth); + if (newWidth <= documentWidth) { + isTransformationDone = true; + logInfo('Munging table is enough.'); + } + } + } + + if (!isTransformationDone && PREFERENCES.mungeTables) { + // OK, that wasn't enough. Try munging all to override any width and nowrap set. + const beforeTransformationWidth = newWidth; + const tds = element.querySelectorAll('td'); + const tmpActionsLog = []; + const areTdsTransformed = addClassToElements(tds, null, 'munged', tmpActionsLog); + if (areTdsTransformed) { + newWidth = element.scrollWidth; + logTransformation('munge td', element, elementWidth, newWidth, documentWidth); + + if (newWidth <= documentWidth) { + isTransformationDone = true; + logInfo('Munging td is enough.'); + } else if (newWidth === beforeTransformationWidth) { + // This transform did not improve things, and it is somewhat risky. + // Back it out, since it's the last transform and we gained nothing. + undoActions(tmpActionsLog); + logInfo('Munging td did not improve things, we undo these actions.'); + } else { + // The transform WAS effective (although not 100%). + // Copy the temporary action log entries over as normal. + actionsLog.push(...tmpActionsLog); + logInfo('Munging td is not enough but is effective.'); + } + } + } + + // If the transformations shrank the width significantly enough, leave them in place. + // We figure that in those cases, the benefits outweight the risk of rendering artifacts. + const transformationRatio = (elementWidth - newWidth) / (elementWidth - documentWidth); + if (!isTransformationDone && transformationRatio > PREFERENCES.minimumEffectiveRatio) { + logInfo('Transforms deemed effective enough.'); + isTransformationDone = true; + } + + if (!isTransformationDone) { + // Reverse all changes if the width is STILL not narrow enough. + // (except the width->maxWidth change, which is not particularly destructive) + undoActions(actionsLog); + if (actionsLog.length > 0) { + logInfo(`All mungers failed, we will reverse ${actionsLog.length} changes.`); + } else { + logInfo(`No mungers applied, width is still too wide.`); + } + return; + } + + logInfo(`Mungers succeeded. We did ${actionsLog.length} changes.`); +} + +/** + * Transform blocks : a div or a textarea + * @param nodes Array of blocks to inspect + * @param documentWidth Width of the overall document + * @param actionsLog Array with all the actions performed + * @returns true if any modification is performed + */ +function transformBlockElements(nodes, documentWidth, actionsLog) { + let elementsAreModified = false; + for (const node of nodes) { + const widthString = node.style.width || node.style.minWidth; + const index = widthString ? widthString.indexOf('px') : -1; + if (index >= 0 && widthString.slice(0, index) > documentWidth) { + saveStyleProperty(node, 'width', actionsLog); + saveStyleProperty(node, 'minWidth', actionsLog); + saveStyleProperty(node, 'maxWidth', actionsLog); + + node.style.width = '100%'; + node.style.minWidth = ''; + node.style.maxWidth = widthString; + + elementsAreModified = true; + } + } + + return elementsAreModified; +} + +/** + * Transform images + * @param images Array of images to inspect + * @param documentWidth Width of the overall document + * @param actionsLog Array with all the actions performed + * @returns true if any modification is performed + */ +function transformImages(images, documentWidth, actionsLog) { + let imagesAreModified = false; + for (const image of images) { + if (image.offsetWidth > documentWidth) { + saveStyleProperty(image, 'width', actionsLog); + saveStyleProperty(image, 'maxWidth', actionsLog); + saveStyleProperty(image, 'height', actionsLog); + + image.style.width = '100%'; + image.style.maxWidth = `${documentWidth}px`; + image.style.height = 'auto'; + + imagesAreModified = true; + } + } + + return imagesAreModified; +} + +/** + * Add a class to a DOM element if a condition is fulfilled + * @param nodes Array of elements to inspect + * @param conditionFunction Function allowing to test a condition with respect to an element. If it is null, the condition is considered true. + * @param classToAdd Class to be added + * @param actionsLog Array with all the actions performed + * @returns true if the class was added to at least one element + */ +function addClassToElements(nodes, conditionFunction, classToAdd, actionsLog) { + let classAdded = false; + for (const node of nodes) { + if (!conditionFunction || conditionFunction(node)) { + if (node.classList.contains(classToAdd)) { continue; } + node.classList.add(classToAdd); + classAdded = true; + actionsLog.push({ function: node.classList.remove, object: node.classList, arguments: [classToAdd] }); + } + } + return classAdded; +} + +/** + * Save a CSS property and its value as a ´data-´ property + * @param node DOM element for which the property will be saved + * @param property Name of the property to save + * @param actionsLog Array with all the actions performed + */ +function saveStyleProperty(node, property, actionsLog) { + const savedName = `data-${property}`; + node.setAttribute(savedName, node.style[property]); + actionsLog.push({ function: undoSetProperty, object: node, arguments: [property, savedName] }); +} + +/** + * Undo a previously changed property + * @param property Property to undo + * @param savedProperty Saved property + */ +function undoSetProperty(property, savedProperty) { + this.style[property] = savedProperty ? this.getAttribute(savedProperty) : ''; +} + +/** + * Undo previous actions + * @param actionsLog Previous actions done + */ +function undoActions(actionsLog) { + for (const action of actionsLog) { + action['function'].apply(action['object'], action['arguments']); + } +} + +/** + * Checks if a table should be munged + * @param table Table HTML object + * @returns true if the object has a width as an attribute or in its style + */ +function shouldMungeTable(table) { + return table.hasAttribute('width') || table.style.width; +} + +// Logger + +function logInfo(text) { + console.info(`[MUNGER_LOG] ${text}`); +} + +function logTransformation(action, element, elementWidth, newWidth, documentWidth) { + logInfo(`Ran ${action} on ${elementDebugName(element)}. OldWidth=${elementWidth}, NewWidth=${newWidth}, DocWidth=${documentWidth}.`); +} + +function elementDebugName(element) { + const id = element.id !== '' ? ` #${element.id}` : ''; + const classes = element.classList.length != 0 ? ` (classes: ${element.classList.value})` : ''; + return `<${element.tagName}${id}${classes}>`; +} diff --git a/MailResources/js/sizeHandler.js b/MailResources/js/sizeHandler.js new file mode 100644 index 000000000..0e3d4f0ec --- /dev/null +++ b/MailResources/js/sizeHandler.js @@ -0,0 +1,65 @@ +/* + Infomaniak Mail - iOS App + Copyright (C) 2022 Infomaniak Network SA + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +function listenToSizeChanges() { + const observer = new ResizeObserver((entries) => { + const height = computeMessageContentHeight(); + window.webkit.messageHandlers.sizeChanged.postMessage({ height }); + }); + + observer.observe(document.querySelector(MESSAGE_SELECTOR)); + return true; +} + +function computeMessageContentHeight() { + const messageContent = document.querySelector(MESSAGE_SELECTOR); + + // Applying the style `overflow: auto` will help to get the correct height + // If child elements have margins, then the height of the div will take this into account + messageContent.style.overflow = 'auto'; + + const messageContentScrollHeight = messageContent.scrollHeight; + const messageContentZoom = parseFloat(messageContent.style.zoom) || 1; + + // Compute body extra size (padding, border, margin) + const documentStyle = window.getComputedStyle(document.body); + const extraSizeElements = ['padding', 'border', 'margin',]; + let extraSize = 0; + for (const element of extraSizeElements) { + const edges = ['top', 'bottom']; + for (const edge of edges) { + const elementSize = readSizeFromString(documentStyle.getPropertyValue(`${element}-${edge}`)); + extraSize += elementSize; + } + } + + const realMailContentSize = messageContentScrollHeight * messageContentZoom; + const fullBodyHeight = Math.ceil(realMailContentSize + extraSize); + + // We can remove the overflow because it's no longer needed + messageContent.style.overflow = null; + + return fullBodyHeight; +} + +function readSizeFromString(data) { + if (data.indexOf('px') === -1) { + return 0; + } + return parseFloat(data); +} diff --git a/MailResources/style.css b/MailResources/style.css deleted file mode 100644 index 35fffe453..000000000 --- a/MailResources/style.css +++ /dev/null @@ -1,224 +0,0 @@ -article,aside,details,figcaption,figure,footer,header,hgroup,nav,section,summary { - display: block; -} - -audio,canvas,video { - display: inline-block; -} - -audio:not([controls]) { - display: none; - height: 0; -} - -[hidden] { - display: none; -} - -html { - font-size: 100%; -} - -button,html,input,select,textarea { - font-family: -apple-system,sans-serif; -} - -body { - font-family: -apple-system,Helvetica,Arial,sans-serif; - font-weight: 400; - margin: 0; - width: 100%; - box-sizing: border-box; - padding: 1rem; - /* outside of tables we force wrap long text */ - word-break: break-word; -} - -table * { - /* inside of tables we use detault wrapping for best formatting (such as Amazon reciept emails) */ - word-break: initial; -} - -table a, table p { - word-break: break-word; -} - -blockquote { - padding: 0 0 0 0.6rem !important; - margin: 0 !important; - border: 1px solid #ccc !important; - border-width: 0 0 0 1px !important; - -webkit-margin-before: 1rem !important; - -webkit-margin-after: 2rem !important; - -webkit-margin-start: 0 !important; - -webkit-margin-end: 0 !important; -} - -blockquote blockquote blockquote { - padding: 0 !important; - border: none !important; -} - -a:focus { - outline: dotted thin; -} - -a:active,a:hover { - outline: 0; -} - -h1 { - font-size: 2em; - margin: .67em 0; -} - -h2 { - font-size: 1.5em; - margin: .83em 0; -} - -h3 { - font-size: 1.17em; - margin: 1em 0; -} - -h4 { - font-size: 1em; - margin: 1.33em 0; -} - -h5 { - font-size: .83em; - margin: 1.67em 0; -} - -h6 { - font-size: .75em; - margin: 2.33em 0; -} - -h1, h2, h3, h4, h5, h6 { - line-height: 120%; -} - -abbr[title] { - border-bottom: 1px dotted; -} - -b,strong { - font-weight: 700; -} - -dfn { - font-style: italic; -} - -mark { - background: #ff0; - color: #000; -} - -p,pre { - margin: 1em 0; -} - -code,kbd,pre,samp { - font-family: monospace,serif; - font-size: 1em; -} - -pre { - white-space: pre; - white-space: pre-wrap; - word-wrap: break-word; -} - -q { - quotes: none; -} - -q:after,q:before { - content: none; -} - -small { - font-size: 80%; -} - -sub,sup { - font-size: 75%; - line-height: 0; - position: relative; - vertical-align: baseline; -} - -sup { - top: -.5em; -} - -sub { - bottom: -.25em; -} - -dl,menu,ol,ul { - margin: 1em 0; -} - -dd { - margin: 0 0 0 40px; -} - -menu,ol,ul { - padding: 0 0 0 40px; -} - -nav ol,nav ul { - list-style: none; -} - -img { - border: 0; - -ms-interpolation-mode: bicubic; - max-width: 100%; - /* will prevent embed image improper sizing */ - /* height: auto; remove it if test ok*/ -} - -td { - max-width: 100%; -} - -table { - /* Some html newsletters set up inline style for tables to take 100% of height and then add footer contents like Unsubscribe button and credits. This practice breaks MessageBodyViewController layout: webView does not include footer into contentSize because consider it to be scrollable. No matter what, webView will place footer below the bottomline of the view in order to make users scroll for it. Since we do not want webViews to be scrollable (in order to support conversation mode), we have to override this styling practice and let table take as much space as its content really needs and place the footer just below. */ - height: auto !important; - min-height: auto !important; -} - -svg:not(:root) { - overflow: hidden; -} - -figure,form { - margin: 0; -} - -fieldset { - border: 1px solid silver; - margin: 0 2px; - padding: .35em .625em .75em; -} - -legend { - border: 0; - padding: 0; - white-space: normal; -} - - -.pm_font_larger { - font-size: 1.8em; -} - -*[style*="min-height"] { - min-height: initial !important; -} diff --git a/Project.swift b/Project.swift index 562ebca02..3508f9704 100644 --- a/Project.swift +++ b/Project.swift @@ -65,7 +65,8 @@ let project = Project(name: "Mail", "MailResources/**/*.strings", "MailResources/**/*.stringsdict", "MailResources/**/*.json", - "MailResources/**/*.css" + "MailResources/**/*.css", + "MailResources/**/*.js" ], entitlements: "MailResources/Mail.entitlements", scripts: [ @@ -140,7 +141,8 @@ let project = Project(name: "Mail", "MailResources/**/*.strings", "MailResources/**/*.stringsdict", "MailResources/**/*.json", - "MailResources/**/*.css" + "MailResources/**/*.css", + "MailResources/**/*.js" ], settings: .settings(base: baseSettings) ),