diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 73f2a6156..5b36398cd 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -158,6 +158,7 @@ 7BFD1A8A2745C4F000FB91B9 /* Permissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BFD1A892745C4F000FB91B9 /* Permissions.swift */; }; 7BFD1A8C2747150E00FB91B9 /* TurnServerInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BFD1A8B2747150E00FB91B9 /* TurnServerInfo.swift */; }; 7BFD1A972747689000FB91B9 /* Session-Turn-Server in Resources */ = {isa = PBXBuildFile; fileRef = 7BFD1A962747689000FB91B9 /* Session-Turn-Server */; }; + 943C6D842B86B5F1004ACE64 /* Localization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 943C6D832B86B5F1004ACE64 /* Localization.swift */; }; 99978E3F7A80275823CA9014 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_SessionNotificationServiceExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 29E827FDF6C1032BB985740C /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_SessionNotificationServiceExtension.framework */; }; A11CD70D17FA230600A2D1B1 /* QuartzCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A11CD70C17FA230600A2D1B1 /* QuartzCore.framework */; }; A163E8AB16F3F6AA0094D68B /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A163E8AA16F3F6A90094D68B /* Security.framework */; }; @@ -1292,6 +1293,7 @@ 8E946CB54A221018E23599DE /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig"; path = "Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig"; sourceTree = ""; }; 92E8569C96285EE3CDB5960D /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SignalUtilitiesKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SignalUtilitiesKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 93359C81CF2660040B7CD106 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionUtilitiesKit_SessionUtilitiesKitTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionUtilitiesKit_SessionUtilitiesKitTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 943C6D832B86B5F1004ACE64 /* Localization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Localization.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; }; A1C32D4D17A0652C000A904E /* AddressBook.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AddressBook.framework; path = System/Library/Frameworks/AddressBook.framework; sourceTree = SDKROOT; }; @@ -2675,6 +2677,7 @@ 7B0EFDED274F598600FFAAE7 /* TimestampUtils.swift */, FD428B1A2B4B6098006D0888 /* Notifications+Lifecycle.swift */, FD428B202B4B75EA006D0888 /* Singleton.swift */, + 943C6D832B86B5F1004ACE64 /* Localization.swift */, ); path = General; sourceTree = ""; @@ -5799,6 +5802,7 @@ files = ( FDE658A129418C7900A33BC1 /* CryptoKit+Utilities.swift in Sources */, FDFD645927F26C6800808CA1 /* Array+Utilities.swift in Sources */, + 943C6D842B86B5F1004ACE64 /* Localization.swift in Sources */, 7B1D74B027C365960030B423 /* Timer+MainThread.swift in Sources */, FD428B192B4B576F006D0888 /* AppContext.swift in Sources */, C32C5D83256DD5B6003C73A2 /* SSKKeychainStorage.swift in Sources */, diff --git a/Session/Meta/Translations/en.lproj/Localizable.strings b/Session/Meta/Translations/en.lproj/Localizable.strings index 5e31caeff..99421a26d 100644 --- a/Session/Meta/Translations/en.lproj/Localizable.strings +++ b/Session/Meta/Translations/en.lproj/Localizable.strings @@ -1320,3 +1320,4 @@ The point that a message will disappear in a disappearing message update message "disappearingMessagesUpdatedYou" = "You updated disappearing message settings."; "groupInviteDelete" = "Are you sure you want to delete this group invite?"; "notificationsMuted" = "Muted"; +"none" = "None"; diff --git a/SessionMessagingKit/Utilities/Preferences.swift b/SessionMessagingKit/Utilities/Preferences.swift index 66b0bd2f8..9d1728723 100644 --- a/SessionMessagingKit/Utilities/Preferences.swift +++ b/SessionMessagingKit/Utilities/Preferences.swift @@ -212,7 +212,7 @@ public enum Preferences { // Other case .messageSent: return "Message Sent" - case .none: return "None" + case .none: return "none".localized() } } diff --git a/SessionUtilitiesKit/General/Localization.swift b/SessionUtilitiesKit/General/Localization.swift new file mode 100644 index 000000000..23c7e09c2 --- /dev/null +++ b/SessionUtilitiesKit/General/Localization.swift @@ -0,0 +1,262 @@ +// Copyright © 2024 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +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 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 { + private let template: String + private var replacements: [String : String] = [:] + + // MARK: - Initialization + + public init(template: String) { + self.template = template + } + + // MARK: - DSL + + public func put(key: String, value: CustomStringConvertible) -> LocalizationHelper { + replacements[key] = value.description + return self + } + + public func localized() -> String { + // If the localized string matches the key provided then the localisation failed + var localizedString: String = NSLocalizedString(template, comment: "") + + for (key, value) in replacements { + localizedString = localizedString.replacingOccurrences(of: tokenize(key), with: value) + } + + return localizedString + } + + public func localizedFormatted(in view: FontAccessible) -> NSAttributedString { + return localizedFormatted(baseFont: (view.fontValue ?? .systemFont(ofSize: 14))) + } + + // MARK: - Internal functions + + private func tokenize(_ key: String) -> String { + return "{" + key + "}" + } + + private func localizedFormatted(baseFont: UIFont) -> NSAttributedString { + return NSAttributedString(stringWithHTMLTags: localized(), font: baseFont) + } + + // MARK: - CustomStringConvertible + + public var description: String { + // Fallback to the localised + return self.localized() + } +} + +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) + } + +// func localized() -> String { +// return LocalizationHelper(template: self).localized() +// } + + func localizedFormatted(in view: FontAccessible) -> NSAttributedString { + return LocalizationHelper(template: self).localizedFormatted(in: view) + } +}