Skip to content

Commit

Permalink
Merge pull request #844 from Infomaniak/webview-width-changed
Browse files Browse the repository at this point in the history
feat(MessageView): Improve mail when width changes
  • Loading branch information
valentinperignon committed Jul 4, 2023
2 parents e08ddd0 + 9a55ac8 commit c78cd61
Show file tree
Hide file tree
Showing 4 changed files with 147 additions and 69 deletions.
149 changes: 95 additions & 54 deletions Mail/Views/Thread/WebView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,89 +16,130 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

import Combine
import MailCore
import SwiftUI
import WebKit

struct WebView: UIViewRepresentable {
@Environment(\.window) private var window

@ObservedObject var model: WebViewModel

let messageUid: String
enum JavaScriptDeclaration {
case normalizeMessageWidth(CGFloat, String)
case removeAllProperties
case documentReadyState

var description: String {
switch self {
case .normalizeMessageWidth(let width, let messageUid):
return "normalizeMessageWidth(\(width), \(messageUid))"
case .removeAllProperties:
return "removeAllProperties()"
case .documentReadyState:
return "document.readyState"
}
}
}

private var webView: WKWebView {
return model.webView
extension WKWebView {
@discardableResult
func evaluateJavaScript(_ declaration: JavaScriptDeclaration) async throws -> Any {
return try await evaluateJavaScript(declaration.description)
}
}

class Coordinator: NSObject, WKNavigationDelegate {
var parent: WebView
final class WebViewController: UIViewController {
var model: WebViewModel!
var messageUid: String!

init(_ parent: WebView) {
self.parent = parent
}
private let widthSubject = PassthroughSubject<Double, Never>()
private var widthSubscriber: AnyCancellable?

func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
// run the JS function `listenToSizeChanges` early. Prevent issues with distant resources not available.
Task { @MainActor in
try await webView.evaluateJavaScript("listenToSizeChanges()")
}
}
override func loadView() {
view = model.webView
view.translatesAutoresizingMaskIntoConstraints = false

func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
Task { @MainActor in
// 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 }
setUpWebView(model.webView)

_ = 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 let url = navigationAction.request.url, Constants.isMailTo(url) {
decisionHandler(.cancel)
(parent.window?.windowScene?.delegate as? SceneDelegate)?.handleUrlOpen(url)
return
}

if navigationAction.navigationType == .linkActivated {
if let url = navigationAction.request.url {
decisionHandler(.cancel)
UIApplication.shared.open(url)
widthSubscriber = widthSubject
.debounce(for: .seconds(0.3), scheduler: DispatchQueue.main)
.sink { newWidth in
Task {
try await self.normalizeMessageWidth(webViewWidth: CGFloat(newWidth))
}
} else {
decisionHandler(.allow)
}
}
}

func makeCoordinator() -> Coordinator {
Coordinator(self)
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
widthSubject.send(size.width)
}

func makeUIView(context: Context) -> WKWebView {
webView.navigationDelegate = context.coordinator
private func setUpWebView(_ webView: WKWebView) {
webView.navigationDelegate = self
webView.scrollView.bounces = false
webView.scrollView.bouncesZoom = false
webView.scrollView.showsVerticalScrollIndicator = false
webView.scrollView.showsHorizontalScrollIndicator = true
webView.scrollView.alwaysBounceVertical = false
webView.scrollView.alwaysBounceHorizontal = false
webView.scrollView.contentInsetAdjustmentBehavior = .never

#if DEBUG
if #available(iOS 16.4, *) {
webView.isInspectable = true
}
#endif
return webView
}

func updateUIView(_ uiView: WKWebView, context: Context) {
// needed for UIViewRepresentable
private func normalizeMessageWidth(webViewWidth width: CGFloat) async throws {
try await model.webView.evaluateJavaScript(.normalizeMessageWidth(width, messageUid ?? ""))
}
}

extension WebViewController: WKNavigationDelegate {
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
Task { @MainActor in
// Fix CSS properties and adapt the mail to the screen size once the resources are loaded
let readyState = try await webView.evaluateJavaScript(.documentReadyState) as? String
guard readyState == "complete" else { return }

try await webView.evaluateJavaScript(.removeAllProperties)
try await normalizeMessageWidth(webViewWidth: webView.frame.width)
}
}

func webView(
_ webView: WKWebView,
decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
) {
if let url = navigationAction.request.url, Constants.isMailTo(url) {
decisionHandler(.cancel)
(view.window?.windowScene?.delegate as? SceneDelegate)?.handleUrlOpen(url)
return
}

if navigationAction.navigationType == .linkActivated {
if let url = navigationAction.request.url {
decisionHandler(.cancel)
UIApplication.shared.open(url)
}
} else {
decisionHandler(.allow)
}
}
}

struct WebView: UIViewControllerRepresentable {
let model: WebViewModel
let messageUid: String

func makeUIViewController(context: Context) -> WebViewController {
let controller = WebViewController()
controller.model = model
controller.messageUid = messageUid
return controller
}

func updateUIViewController(_ uiViewController: WebViewController, context: Context) {
// Not needed
}
}
2 changes: 1 addition & 1 deletion Mail/Views/Thread/WebViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ final class WebViewModel: NSObject, ObservableObject {
}

private func loadScripts(configuration: WKWebViewConfiguration) {
var scripts = ["javaScriptBridge", "fixEmailStyle", "sizeHandler"]
var scripts = ["javaScriptBridge", "sizeHandler", "fixEmailStyle"]
#if DEBUG
scripts.insert("captureLog", at: 0)
#endif
Expand Down
60 changes: 46 additions & 14 deletions MailResources/js/mungeEmail.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,19 @@ const PREFERENCES = {
normalizeMessageWidths: true,
mungeImages: true,
mungeTables: true,
minimumEffectiveRatio: 0.7
minimumEffectiveRatio: 0.7,
undoPreviousChanges: true
};

/**
* Changes made to munge for each element normalized
* Format of entries :
* {
* <HTMLElementId><MessageUid>: { function: fn, object: object, arguments: [list of arguments] }
* }
*/
let actionsLog = {};

// Functions

/**
Expand Down Expand Up @@ -56,6 +66,13 @@ function normalizeElementWidths(elements, webViewWidth, messageUid) {
for (const element of elements) {
logInfo(`Current element: ${elementDebugName(element)}.`);

// If the script has already been run, we can undo the changes we've made and start again from scratch
let currentActionsLog = getActionsLog(element, messageUid);
if (PREFERENCES.undoPreviousChanges && currentActionsLog.length > 0) {
logInfo('We need to undo changes from a previous run.');
undoActions(currentActionsLog);
}

// Reset any existing normalization
const originalZoom = element.style.zoom;
if (originalZoom) {
Expand All @@ -65,7 +82,7 @@ function normalizeElementWidths(elements, webViewWidth, messageUid) {

const originalWidth = element.style.width;
element.style.width = `${webViewWidth}px`;
transformContent(element, webViewWidth, element.scrollWidth);
transformContent(element, webViewWidth, element.scrollWidth, messageUid);

if (PREFERENCES.normalizeMessageWidths) {
const newZoom = documentWidth / element.scrollWidth;
Expand All @@ -88,7 +105,7 @@ function normalizeElementWidths(elements, webViewWidth, messageUid) {
* @param documentWidth Width of the overall document
* @param elementWidth Element width before any action is done
*/
function transformContent(element, documentWidth, elementWidth) {
function transformContent(element, documentWidth, elementWidth, messageUid) {
if (elementWidth <= documentWidth) {
logInfo(`Element doesn't need to be transformed. Current size: ${elementWidth}, DocumentWidth: ${documentWidth}.`);
return;
Expand All @@ -97,14 +114,14 @@ function transformContent(element, documentWidth, elementWidth) {

let newWidth = elementWidth;
let isTransformationDone = false;
/** Format of entries : { function: fn, object: object, arguments: [list of arguments] } */
let actionsLog = [];

let currentActionsLog = getActionsLog(element, messageUid);

// 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);
const areNodesTransformed = transformBlockElements(nodes, documentWidth, currentActionsLog);
if (areNodesTransformed) {
newWidth = element.scrollWidth;
logTransformation('munge div[style] and textarea[style]', element, elementWidth, newWidth, documentWidth);
Expand All @@ -118,7 +135,7 @@ function transformContent(element, documentWidth, elementWidth) {
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);
const areImagesTransformed = transformImages(images, documentWidth, currentActionsLog);
if (areImagesTransformed) {
newWidth = element.scrollWidth;
logTransformation('munge img', element, elementWidth, newWidth, documentWidth);
Expand All @@ -134,7 +151,7 @@ function transformContent(element, documentWidth, elementWidth) {
// 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);
const areTablesTransformed = addClassToElements(tables, shouldMungeTable, 'munged', currentActionsLog);
if (areTablesTransformed) {
newWidth = element.scrollWidth;
logTransformation('munge table', element, elementWidth, newWidth, documentWidth);
Expand Down Expand Up @@ -166,7 +183,7 @@ function transformContent(element, documentWidth, elementWidth) {
} else {
// The transform WAS effective (although not 100%).
// Copy the temporary action log entries over as normal.
actionsLog.push(...tmpActionsLog);
currentActionsLog.push(...tmpActionsLog);
logInfo('Munging td is not enough but is effective.');
}
}
Expand All @@ -183,16 +200,16 @@ function transformContent(element, documentWidth, elementWidth) {
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.`);
undoActions(currentActionsLog);
if (currentActionsLog.length > 0) {
logInfo(`All mungers failed, we will reverse ${currentActionsLog.length} changes.`);
} else {
logInfo(`No mungers applied, width is still too wide.`);
}
return;
}

logInfo(`Mungers succeeded. We did ${actionsLog.length} changes.`);
logInfo(`Mungers succeeded. We did ${currentActionsLog.length} changes.`);
}

/**
Expand Down Expand Up @@ -296,7 +313,8 @@ function undoSetProperty(property, savedProperty) {
* @param actionsLog Previous actions done
*/
function undoActions(actionsLog) {
for (const action of actionsLog) {
while (actionsLog.length > 0) {
const action = actionsLog.pop();
action['function'].apply(action['object'], action['arguments']);
}
}
Expand All @@ -310,6 +328,20 @@ function shouldMungeTable(table) {
return table.hasAttribute('width') || table.style.width;
}

/**
* Get the actionsLog associated with the element to be modified
* @param element Element to be modified
* @param messageUid MessageUid associated to the element
* @returns {string} Id of the actionsLog
*/
function getActionsLog(element, messageUid) {
const id= `${element.id}${messageUid}`;
if (actionsLog[id] === undefined) {
actionsLog[id] = [];
}
return actionsLog[id];
}

// Logger

function logInfo(text) {
Expand Down
5 changes: 5 additions & 0 deletions MailResources/js/sizeHandler.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

// We need to call the function as quickly as possible to obtain the webview size
document.addEventListener('DOMContentLoaded', () => {
listenToSizeChanges();
});

function listenToSizeChanges() {
const observer = new ResizeObserver((entries) => {
const height = computeMessageContentHeight();
Expand Down

0 comments on commit c78cd61

Please sign in to comment.