diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 40b6a1d0a..287930b10 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -191,6 +191,7 @@ 9473386E2BDF5F3E00B9E169 /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 9473386D2BDF5F3E00B9E169 /* InfoPlist.xcstrings */; }; 94B3DC172AF8592200C88531 /* QuoteView_SwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94B3DC162AF8592200C88531 /* QuoteView_SwiftUI.swift */; }; 94C5DCB02BE88170003AA8C5 /* BezierPathView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94C5DCAF2BE88170003AA8C5 /* BezierPathView.swift */; }; + 94E9BC0D2C7BFBDA006984EA /* Localization+Style.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94E9BC0C2C7BFBDA006984EA /* Localization+Style.swift */; }; 94FC7A8F2C759F47004A8146 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94FC7A8D2C759805004A8146 /* Constants.swift */; }; A11CD70D17FA230600A2D1B1 /* QuartzCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A11CD70C17FA230600A2D1B1 /* QuartzCore.framework */; }; A163E8AB16F3F6AA0094D68B /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A163E8AA16F3F6A90094D68B /* Security.framework */; }; @@ -1380,6 +1381,7 @@ 9473386D2BDF5F3E00B9E169 /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = ""; }; 94B3DC162AF8592200C88531 /* QuoteView_SwiftUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuoteView_SwiftUI.swift; sourceTree = ""; }; 94C5DCAF2BE88170003AA8C5 /* BezierPathView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BezierPathView.swift; sourceTree = ""; }; + 94E9BC0C2C7BFBDA006984EA /* Localization+Style.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Localization+Style.swift"; sourceTree = ""; }; 94FC7A8D2C759805004A8146 /* Constants.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; A11CD70C17FA230600A2D1B1 /* QuartzCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuartzCore.framework; path = System/Library/Frameworks/QuartzCore.framework; sourceTree = SDKROOT; }; A163E8AA16F3F6A90094D68B /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; }; @@ -2986,6 +2988,7 @@ FDBB25E62988BBBD00F1508E /* UIContextualAction+Theming.swift */, 7BA1E0E72A8087DB00123D0D /* SwiftUI+Utilities.swift */, 9422EE2A2B8C3A97004C740D /* String+Utilities.swift */, + 94E9BC0C2C7BFBDA006984EA /* Localization+Style.swift */, ); path = Utilities; sourceTree = ""; @@ -3432,7 +3435,6 @@ C38EF307255B6DBE007E1867 /* UIGestureRecognizer+OWS.swift */, C33FDBD3255A581800E217F9 /* OWSSignalAddress.swift */, FD6A392B2C2AC51900762359 /* AppVersion.swift */, - C38EF3E4255B6DF4007E1867 /* CommonStrings.swift */, C38EF304255B6DBE007E1867 /* ImageCache.swift */, C38EF2F2255B6DBC007E1867 /* Searcher.swift */, C3F0A52F255C80BC007BE2A3 /* NoopNotificationsManager.swift */, @@ -5540,6 +5542,7 @@ FD37E9CF28A1EB1B003AE748 /* Theme.swift in Sources */, C331FFB92558FA8D00070591 /* UIView+Constraints.swift in Sources */, FD0B77B029B69A65009169BA /* TopBannerController.swift in Sources */, + 94E9BC0D2C7BFBDA006984EA /* Localization+Style.swift in Sources */, FD37E9F628A5F106003AE748 /* Configuration.swift in Sources */, 7BF8D1FB2A70AF57005F1D6E /* SwiftUI+Theme.swift in Sources */, FDBB25E72988BBBE00F1508E /* UIContextualAction+Theming.swift in Sources */, diff --git a/SessionUIKit/Utilities/Localization+Style.swift b/SessionUIKit/Utilities/Localization+Style.swift new file mode 100644 index 000000000..024e5e9c4 --- /dev/null +++ b/SessionUIKit/Utilities/Localization+Style.swift @@ -0,0 +1,235 @@ +// Copyright © 2024 Rangeproof Pty Ltd. All rights reserved. + +// stringlint:disable + +import UIKit +import SessionUtilitiesKit + +public extension NSAttributedString { + /// These are the tags we current support formatting for + enum HTMLTag: String { + /// The regex to recognize an open or close tag + /// + /// **Note:** The `{1,X}` defines a minimum and maximum tag length and may need to be updated if we add support + /// for longer tags that are currently supported + static let regexPattern: String = #"<(?\/)?(?[A-Za-z0-9]{1,1})>"# + + case bold = "b" + case italic = "i" + case underline = "u" + case strikethrough = "s" + case primaryTheme = "span" + + // MARK: - Functions + + static func from(_ stringValue: String) -> (tag: HTMLTag, closeTag: Bool)? { + let isCloseTag: Bool = stringValue.starts(with: "", with: "") + ).map { ($0, isCloseTag) } + } + + func format(with font: UIFont) -> [NSAttributedString.Key: Any] { + /// **Note:** Constructing a `UIFont` with a `size`of `0` will preserve the textSize + switch self { + case .bold: return [ + .font: UIFont( + descriptor: (font.fontDescriptor.withSymbolicTraits(.traitBold) ?? font.fontDescriptor), + size: 0 + ) + ] + case .italic: return [ + .font: UIFont( + descriptor: (font.fontDescriptor.withSymbolicTraits(.traitItalic) ?? font.fontDescriptor), + size: 0 + ) + ] + case .underline: return [.underlineStyle: NSUnderlineStyle.single.rawValue] + case .strikethrough: return [.strikethroughStyle: NSUnderlineStyle.single.rawValue] + case .primaryTheme: return [.foregroundColor: ThemeManager.primaryColor.color] + } + } + } + + convenience init(stringWithHTMLTags: String?, font: UIFont) { + guard + let targetString: String = stringWithHTMLTags, + let expression: NSRegularExpression = try? NSRegularExpression( + pattern: HTMLTag.regexPattern, + options: [.caseInsensitive, .dotMatchesLineSeparators] + ) + else { + self.init(string: (stringWithHTMLTags ?? "")) + return + } + + /// Construct the needed types + /// + /// **Note:** We use an `NSAttributedString` for retrieving string ranges because if we don't then emoji characters + /// can cause odd behaviours with accessing ranges so this simplifies the logic + let attrString: NSAttributedString = NSAttributedString(string: targetString) + let stringLength: Int = targetString.utf16.count + var partsAndTags: [(part: String, tags: [HTMLTag])] = [] + var openTags: [HTMLTag: Int] = [:] + var lastMatch: NSTextCheckingResult? + + /// Enumerate through the HTMLTag matches and build up a list of formats to apply + expression.enumerateMatches(in: targetString, range: NSMakeRange(0, stringLength)) { match, _, _ in + guard let currentMatch: NSTextCheckingResult = match else { return } + + /// Store the positions for readability + let lastMatchEnd: Int = (lastMatch?.range.upperBound ?? 0) + let currentMatchStart: Int = currentMatch.range.lowerBound + let currentMatchEnd: Int = currentMatch.range.upperBound + + /// Ignore invalid ranges + guard (currentMatchStart > lastMatchEnd) || (currentMatchStart == lastMatchEnd && currentMatchStart == 0) else { return } + + /// Retrieve the tag and content values, store the content and any tags which are currently applied so we can construct the + /// formatted string at the end + let tagMatch: String = attrString[currentMatchStart.. 1: openTags[tagInfo.tag] = (openCount - 1) + + /// Otherwise we have a valid format chunk so should collapse it + case (true, .some): openTags[tagInfo.tag] = nil + + /// Ignore close tags with no corresponding open tags + case (true, .none): break + } + } + + /// Store the the `lastMatch` value for appending the final part of the content + lastMatch = currentMatch + } + + /// If we don't have a `lastMatch` value then we weren't able to get a single valid tag match so just stop here are return the `targetString` + guard let finalMatch: NSTextCheckingResult = lastMatch else { + self.init(string: targetString) + return + } + + /// If the final regex match isn't at the end of the string then we need to append the final part of the string to avoid it getting truncated + /// + /// **Note:** When there is an opening tag but no closing tag browsers seem to apply the style from the opening tag to the end of + /// the string so we should do the same here + if stringLength > finalMatch.range.upperBound { + partsAndTags.append((attrString[finalMatch.range.upperBound...], Array(openTags.keys))) + } + + /// Lastly we should construct the attributed string, applying the desired formatting + self.init( + attributedString: partsAndTags.reduce(into: NSMutableAttributedString()) { result, next in + result.append(NSAttributedString(string: next.part, attributes: next.tags.format(with: font))) + } + ) + } + + private subscript(range: Range) -> String { + attributedSubstring(from: NSMakeRange(range.lowerBound, (range.upperBound - range.lowerBound))).string + } + + private subscript(range: PartialRangeFrom) -> String { + let upperBound: Int = self.string.utf16.count + return attributedSubstring(from: NSMakeRange(range.lowerBound, (upperBound - range.lowerBound))).string + } + + private subscript(range: PartialRangeThrough) -> String { + attributedSubstring(from: NSMakeRange(0, range.upperBound)).string + } +} + +private extension Collection where Element == NSAttributedString.HTMLTag { + func format(with font: UIFont) -> [NSAttributedString.Key: Any] { + func fontWith(_ font: UIFont, traits: UIFontDescriptor.SymbolicTraits) -> UIFont { + /// **Note:** Constructing a `UIFont` with a `size`of `0` will preserve the textSize + return UIFont( + descriptor: (font.fontDescriptor.withSymbolicTraits(traits) ?? font.fontDescriptor), + size: 0 + ) + } + + return self.reduce(into: [NSAttributedString.Key: Any]()) { result, tag in + switch tag { + case .bold where self.contains(.italic), .italic where self.contains(.bold): + result[.font] = fontWith(font, traits: [.traitBold, .traitItalic]) + + case .bold: result[.font] = fontWith(font, traits: [.traitBold]) + case .italic: result[.font] = fontWith(font, traits: [.traitItalic]) + case .underline: result[.underlineStyle] = NSUnderlineStyle.single.rawValue + case .strikethrough: result[.strikethroughStyle] = NSUnderlineStyle.single.rawValue + case .primaryTheme: result[.foregroundColor] = ThemeManager.primaryColor.color + } + } + } +} + +public extension LocalizationHelper { + func localizedFormatted(in view: FontAccessible) -> NSAttributedString { + return localizedFormatted(baseFont: (view.fontValue ?? .systemFont(ofSize: 14))) + } + + func localizedFormatted(baseFont: UIFont) -> NSAttributedString { + return NSAttributedString(stringWithHTMLTags: localized(), font: baseFont) + } + + func localizedDeformatted() -> String { + return NSAttributedString(stringWithHTMLTags: localized(), font: .systemFont(ofSize: 14)).string + } + +} + +public protocol FontAccessible { + var fontValue: UIFont? { get } +} + +public protocol DirectFontAccessible: FontAccessible { + var font: UIFont? { get } +} + +extension DirectFontAccessible { + public var fontValue: UIFont? { font } +} + +// UILabel has a `font!` value so we need to conform to a different protocol +extension UILabel: FontAccessible { + public var fontValue: UIFont? { + get { self.font } + } +} +extension UITextField: DirectFontAccessible {} +extension UITextView: DirectFontAccessible {} + +public extension String { + func localizedFormatted(in view: FontAccessible) -> NSAttributedString { + return LocalizationHelper(template: self).localizedFormatted(in: view) + } + + func formatted(in view: FontAccessible) -> NSAttributedString { + return NSAttributedString(stringWithHTMLTags: self, font: (view.fontValue ?? .systemFont(ofSize: 14))) + } + + func formatted(baseFont: UIFont) -> NSAttributedString { + return NSAttributedString(stringWithHTMLTags: self, font: baseFont) + } + + func deformatted() -> String { + return NSAttributedString(stringWithHTMLTags: self, font: .systemFont(ofSize: 14)).string + } +} diff --git a/SessionUtilitiesKit/General/Localization.swift b/SessionUtilitiesKit/General/Localization.swift index 485c9d70d..fe8a0c290 100644 --- a/SessionUtilitiesKit/General/Localization.swift +++ b/SessionUtilitiesKit/General/Localization.swift @@ -4,178 +4,6 @@ import UIKit -public extension NSAttributedString { - /// These are the tags we current support formatting for - enum HTMLTag: String { - /// The regex to recognize an open or close tag - /// - /// **Note:** The `{1,X}` defines a minimum and maximum tag length and may need to be updated if we add support - /// for longer tags that are currently supported - static let regexPattern: String = #"<(?\/)?(?[A-Za-z0-9]{1,1})>"# - - case bold = "b" - case italic = "i" - case underline = "u" - case strikethrough = "s" - - // MARK: - Functions - - static func from(_ stringValue: String) -> (tag: HTMLTag, closeTag: Bool)? { - let isCloseTag: Bool = stringValue.starts(with: "", with: "") - ).map { ($0, isCloseTag) } - } - - func format(with font: UIFont) -> [NSAttributedString.Key: Any] { - /// **Note:** Constructing a `UIFont` with a `size`of `0` will preserve the textSize - switch self { - case .bold: return [ - .font: UIFont( - descriptor: (font.fontDescriptor.withSymbolicTraits(.traitBold) ?? font.fontDescriptor), - size: 0 - ) - ] - case .italic: return [ - .font: UIFont( - descriptor: (font.fontDescriptor.withSymbolicTraits(.traitItalic) ?? font.fontDescriptor), - size: 0 - ) - ] - case .underline: return [.underlineStyle: NSUnderlineStyle.single.rawValue] - case .strikethrough: return [.strikethroughStyle: NSUnderlineStyle.single.rawValue] - } - } - } - - convenience init(stringWithHTMLTags: String?, font: UIFont) { - guard - let targetString: String = stringWithHTMLTags, - let expression: NSRegularExpression = try? NSRegularExpression( - pattern: HTMLTag.regexPattern, - options: [.caseInsensitive, .dotMatchesLineSeparators] - ) - else { - self.init(string: (stringWithHTMLTags ?? "")) - return - } - - /// Construct the needed types - /// - /// **Note:** We use an `NSAttributedString` for retrieving string ranges because if we don't then emoji characters - /// can cause odd behaviours with accessing ranges so this simplifies the logic - let attrString: NSAttributedString = NSAttributedString(string: targetString) - let stringLength: Int = targetString.utf16.count - var partsAndTags: [(part: String, tags: [HTMLTag])] = [] - var openTags: [HTMLTag: Int] = [:] - var lastMatch: NSTextCheckingResult? - - /// Enumerate through the HTMLTag matches and build up a list of formats to apply - expression.enumerateMatches(in: targetString, range: NSMakeRange(0, stringLength)) { match, _, _ in - guard let currentMatch: NSTextCheckingResult = match else { return } - - /// Store the positions for readability - let lastMatchEnd: Int = (lastMatch?.range.upperBound ?? 0) - let currentMatchStart: Int = currentMatch.range.lowerBound - let currentMatchEnd: Int = currentMatch.range.upperBound - - /// Ignore invalid ranges - guard (currentMatchStart > lastMatchEnd) || (currentMatchStart == lastMatchEnd && currentMatchStart == 0) else { return } - - /// Retrieve the tag and content values, store the content and any tags which are currently applied so we can construct the - /// formatted string at the end - let tagMatch: String = attrString[currentMatchStart.. 1: openTags[tagInfo.tag] = (openCount - 1) - - /// Otherwise we have a valid format chunk so should collapse it - case (true, .some): openTags[tagInfo.tag] = nil - - /// Ignore close tags with no corresponding open tags - case (true, .none): break - } - } - - /// Store the the `lastMatch` value for appending the final part of the content - lastMatch = currentMatch - } - - /// If we don't have a `lastMatch` value then we weren't able to get a single valid tag match so just stop here are return the `targetString` - guard let finalMatch: NSTextCheckingResult = lastMatch else { - self.init(string: targetString) - return - } - - /// If the final regex match isn't at the end of the string then we need to append the final part of the string to avoid it getting truncated - /// - /// **Note:** When there is an opening tag but no closing tag browsers seem to apply the style from the opening tag to the end of - /// the string so we should do the same here - if stringLength > finalMatch.range.upperBound { - partsAndTags.append((attrString[finalMatch.range.upperBound...], Array(openTags.keys))) - } - - /// Lastly we should construct the attributed string, applying the desired formatting - self.init( - attributedString: partsAndTags.reduce(into: NSMutableAttributedString()) { result, next in - result.append(NSAttributedString(string: next.part, attributes: next.tags.format(with: font))) - } - ) - } - - private subscript(range: Range) -> String { - attributedSubstring(from: NSMakeRange(range.lowerBound, (range.upperBound - range.lowerBound))).string - } - - private subscript(range: PartialRangeFrom) -> String { - let upperBound: Int = self.string.utf16.count - return attributedSubstring(from: NSMakeRange(range.lowerBound, (upperBound - range.lowerBound))).string - } - - private subscript(range: PartialRangeThrough) -> String { - attributedSubstring(from: NSMakeRange(0, range.upperBound)).string - } -} - -private extension Collection where Element == NSAttributedString.HTMLTag { - func format(with font: UIFont) -> [NSAttributedString.Key: Any] { - func fontWith(_ font: UIFont, traits: UIFontDescriptor.SymbolicTraits) -> UIFont { - /// **Note:** Constructing a `UIFont` with a `size`of `0` will preserve the textSize - return UIFont( - descriptor: (font.fontDescriptor.withSymbolicTraits(traits) ?? font.fontDescriptor), - size: 0 - ) - } - - return self.reduce(into: [NSAttributedString.Key: Any]()) { result, tag in - switch tag { - case .bold where self.contains(.italic), .italic where self.contains(.bold): - result[.font] = fontWith(font, traits: [.traitBold, .traitItalic]) - - case .bold: result[.font] = fontWith(font, traits: [.traitBold]) - case .italic: result[.font] = fontWith(font, traits: [.traitItalic]) - case .underline: result[.underlineStyle] = NSUnderlineStyle.single.rawValue - case .strikethrough: result[.strikethroughStyle] = NSUnderlineStyle.single.rawValue - } - } - } -} - // MARK: - PendingLocalizedString final public class LocalizationHelper: CustomStringConvertible { @@ -224,18 +52,6 @@ final public class LocalizationHelper: CustomStringConvertible { return localizedString } - public func localizedFormatted(in view: FontAccessible) -> NSAttributedString { - return localizedFormatted(baseFont: (view.fontValue ?? .systemFont(ofSize: 14))) - } - - public func localizedFormatted(baseFont: UIFont) -> NSAttributedString { - return NSAttributedString(stringWithHTMLTags: localized(), font: baseFont) - } - - public func localizedDeformatted() -> String { - return NSAttributedString(stringWithHTMLTags: localized(), font: .systemFont(ofSize: 14)).string - } - // MARK: - Internal functions private func tokenize(_ key: String) -> String { @@ -250,27 +66,6 @@ final public class LocalizationHelper: CustomStringConvertible { } } -public protocol FontAccessible { - var fontValue: UIFont? { get } -} - -public protocol DirectFontAccessible: FontAccessible { - var font: UIFont? { get } -} - -extension DirectFontAccessible { - public var fontValue: UIFont? { font } -} - -// UILabel has a `font!` value so we need to conform to a different protocol -extension UILabel: FontAccessible { - public var fontValue: UIFont? { - get { self.font } - } -} -extension UITextField: DirectFontAccessible {} -extension UITextView: DirectFontAccessible {} - public extension String { func put(key: String, value: CustomStringConvertible) -> LocalizationHelper { return LocalizationHelper(template: self).put(key: key, value: value) @@ -283,20 +78,4 @@ public extension String { func localized() -> String { return LocalizationHelper(template: self).localized() } - - func localizedFormatted(in view: FontAccessible) -> NSAttributedString { - return LocalizationHelper(template: self).localizedFormatted(in: view) - } - - func formatted(in view: FontAccessible) -> NSAttributedString { - return NSAttributedString(stringWithHTMLTags: self, font: (view.fontValue ?? .systemFont(ofSize: 14))) - } - - func formatted(baseFont: UIFont) -> NSAttributedString { - return NSAttributedString(stringWithHTMLTags: self, font: baseFont) - } - - func deformatted() -> String { - return NSAttributedString(stringWithHTMLTags: self, font: .systemFont(ofSize: 14)).string - } }