ET-3505 Dynamic type size in message body

This commit is contained in:
Jacek Krasiukianis
2025-11-04 17:09:23 +01:00
committed by MargeBot
parent b5a61b5040
commit aa85c138d4
5 changed files with 188 additions and 4 deletions
@@ -22,6 +22,7 @@ import proton_app_uniffi
import SwiftUI
struct ConversationDetailListView: View {
@Environment(\.dynamicTypeSize) var dynamicTypeSize
@EnvironmentObject var toastStateStore: ToastStateStore
@ObservedObject private var model: ConversationDetailModel
private let mailUserSession: MailUserSession
@@ -114,6 +115,12 @@ struct ConversationDetailListView: View {
.padding(.bottom, messages.count - 1 == index ? 0 : -DS.Spacing.extraLarge)
}
}
/*
When dynamic type size is reduced, the web views do not shrink properly and instead they remain stretched to the size they previously occupied.
Also, the overall scroll position of the conversation is wrong.
The easiest - though not very elegant - way to solve this is to recreate the whole list.
*/
.id(dynamicTypeSize)
.onAppear {
if let scrollToMessage = model.scrollToMessage {
scrollView.scrollTo(scrollToMessage, anchor: .top)
@@ -0,0 +1,16 @@
const elements = document.querySelectorAll(`[style*="font-size"][style*="line-height"]`);
for (const element of elements) {
const currentStyle = element.getAttribute('style');
window.webkit.messageHandlers.scaleStyle.postMessage(currentStyle)
.then(updatedStyle => {
if (!updatedStyle) {
return;
}
element.setAttribute("style", updatedStyle);
}).catch(error => {
console.log(error);
});
}
@@ -0,0 +1,78 @@
// Copyright (c) 2025 Proton Technologies AG
//
// This file is part of Proton Mail.
//
// Proton Mail 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.
//
// Proton Mail 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 Proton Mail. If not, see https://www.gnu.org/licenses/.
import WebKit
final class DynamicTypeSizeMessageHandler: NSObject, WKScriptMessageHandlerWithReply {
enum MessageName: String, CaseIterable {
case scaleStyle
}
private let scalableProperties: [String] = ["font-size", "line-height"]
func userContentController(
_ userContentController: WKUserContentController,
didReceive message: WKScriptMessage
) async -> (Any?, String?) {
let styleString = message.body as! String
let processedStyleString = applyScaling(to: styleString)
return (processedStyleString, nil)
}
func applyScaling(to styleString: String) -> String {
var styleProperties = CSSStyleCoder.decode(styleString: styleString)
for propertyName in scalableProperties {
guard
let unscaledValueString = styleProperties[propertyName],
let match = unscaledValueString.firstMatch(of: /([[:digit:]\.]+)(?:pt|px)/),
let unscaledNumericalValue = Double(match.output.1),
unscaledNumericalValue != 0
else {
continue
}
let scaledNumericalValue = UIFontMetrics.default.scaledValue(for: unscaledNumericalValue)
let scaleFactor = scaledNumericalValue / unscaledNumericalValue
let scaleFactorPropertyName = "--dts-\(propertyName)-scale-factor"
styleProperties[scaleFactorPropertyName] = "\(scaleFactor)"
styleProperties[propertyName] = "calc(\(match.output.0) * var(\(scaleFactorPropertyName))) !important"
}
styleProperties["overflow-wrap"] = "anywhere !important"
styleProperties["text-wrap-mode"] = "wrap !important"
return CSSStyleCoder.encode(properties: styleProperties)
}
}
private enum CSSStyleCoder {
static func decode(styleString: String) -> [String: String] {
styleString.components(separatedBy: ";").reduce(into: [:]) { acc, keyValueString in
let keyValuePair = keyValueString.components(separatedBy: ":").map { $0.trimmingCharacters(in: .whitespaces) }
if !keyValuePair.isEmpty {
acc[keyValuePair[0]] = keyValuePair.last
}
}
}
static func encode(properties: [String: String]) -> String {
properties.map { "\($0): \($1)" }.sorted().joined(separator: ";")
}
}
@@ -26,7 +26,6 @@ extension EnvironmentValues {
}
struct MessageBodyReaderView: UIViewRepresentable {
@Environment(\.webViewPrintingRegistrar) var webViewPrintingRegistrar
@Binding var bodyContentHeight: CGFloat
let body: MessageBody.HTML
let viewWidth: CGFloat
@@ -54,12 +53,24 @@ struct MessageBodyReaderView: UIViewRepresentable {
config.userContentController.add(context.coordinator, name: handlerName.rawValue)
}
let userScripts: [AppScript] = [
.adjustLayoutAndObserveHeight(viewWidth: viewWidth),
.handleEmptyBody,
for messageName in DynamicTypeSizeMessageHandler.MessageName.allCases {
config.userContentController.addScriptMessageHandler(
context.coordinator.dynamicTypeSizeMessageHandler,
contentWorld: .page,
name: messageName.rawValue
)
}
var userScripts: [AppScript] = [
.redirectConsoleLogToAppLogger,
.handleEmptyBody,
.adjustLayoutAndObserveHeight(viewWidth: viewWidth),
]
if context.environment.dynamicTypeSize != .large {
userScripts.append(.dynamicTypeSize)
}
userScripts
.filter(\.isEnabled)
.map { $0.toUserScript() }
@@ -105,6 +116,21 @@ struct MessageBodyReaderView: UIViewRepresentable {
height: fit-content !important;
}
}
@media not print {
body {
font: -apple-system-body;
}
}
@media print {
[style*="--dts-font-size-scale-factor"] {
--dts-font-size-scale-factor: 1 !important;
}
[style*="--dts-line-height-scale-factor"] {
--dts-line-height-scale-factor: 1 !important;
}
}
</style>
"""
let fixedBody = body.rawBody.replacingOccurrences(of: "</head>", with: "\(style)</head>")
@@ -121,6 +147,7 @@ extension MessageBodyReaderView {
class Coordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler, WKUIDelegate {
let parent: MessageBodyReaderView
let dynamicTypeSizeMessageHandler = DynamicTypeSizeMessageHandler()
var urlOpener: URLOpenerProtocol?
private var previouslyReceivedBody: MessageBody.HTML?
private weak var webView: WKWebView?
@@ -291,9 +318,19 @@ extension AppScript {
console.log = function(message) {
window.webkit.messageHandlers.\(HandlerName.consoleLog.rawValue).postMessage(message)
};
console.error = function(message) {
window.webkit.messageHandlers.\(HandlerName.consoleLog.rawValue).postMessage(message)
};
""",
isEnabled: WKWebView.inspectabilityEnabled
)
fileprivate static let dynamicTypeSize: Self = {
let scriptURL = Bundle.main.url(forResource: "DynamicTypeSize", withExtension: "js")!
let source = try! String(contentsOf: scriptURL, encoding: .utf8)
return .init(source: source)
}()
}
private enum HandlerName: String, CaseIterable {
@@ -0,0 +1,46 @@
// Copyright (c) 2025 Proton Technologies AG
//
// This file is part of Proton Mail.
//
// Proton Mail 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.
//
// Proton Mail 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 Proton Mail. If not, see https://www.gnu.org/licenses/.
import Testing
import WebKit
@testable import ProtonMail
@MainActor
struct DynamicTypeSizeMessageHandlerTests {
private let sut = DynamicTypeSizeMessageHandler()
@Test
func scalesFontSizeAndLineHeightSpecifiedInAbsoluteUnitsByApplyingScaleFactor() {
let incomingStyle = "font-size: 13px; line-height: 14px"
let expectedStyle =
"--dts-font-size-scale-factor: 1.0;--dts-line-height-scale-factor: 1.0;font-size: calc(13px * var(--dts-font-size-scale-factor)) !important;line-height: calc(14px * var(--dts-line-height-scale-factor)) !important;overflow-wrap: anywhere !important;text-wrap-mode: wrap !important"
#expect(sut.applyScaling(to: incomingStyle) == expectedStyle)
}
@Test
func doesNotModifyPropertiesWithRelativeUnits() {
let incomingStyle = "font-family: Roboto-Regular, Helvetica, Arial, sans-serif; font-size: 13px; color: #000000de; line-height: 1.6"
let expectedStyle =
"--dts-font-size-scale-factor: 1.0;color: #000000de;font-family: Roboto-Regular, Helvetica, Arial, sans-serif;font-size: calc(13px * var(--dts-font-size-scale-factor)) !important;line-height: 1.6;overflow-wrap: anywhere !important;text-wrap-mode: wrap !important"
#expect(sut.applyScaling(to: incomingStyle) == expectedStyle)
}
}