mirror of
https://github.com/lux-tel/session-ios.git
synced 2026-10-30 20:38:32 +00:00
The `SessionThreadViewModel` has been renamed to `ConversationInfoViewModel` and is now a simple struct (as opposed to a FetchableRecord with a handful of behemoth database queries) The `ConversationInfoViewModel` and `MessageViewModel` now get initialised with a `ConversationDataCache` which is an object that holds a bunch of [ID -> Data] stores for various types that are needed to construct the aforementioned view models. The `ConversationDataCache` will generally be populated via the `ConversationDataHelper` which, when combined with our `ObservableEvent` system, is where the majority of the work for this refactoring has gone. This helper essentially has 4 parts: - `determineFetchRequirements`: Reviews any events which have come through and determines whether anything needs to be fetched from the database - `applyNonDatabaseEvents`: Updates the cached data directly based on delta information included in the `ObservableEvent` instances - `fetchFromDatabase`: Fetches any data from the database based on the info returned from `determineFetchRequirements`, this function will also resolve any dependant data that newly fetched objects need if they aren't already in the cache - `fetchFromLibSession`: Retrieves requested data from libSession Using the above setup all screens which contain conversation or message data now use these two view models, and can share the state of the view models between each other to reduce the need to refetch data when entering a new screen Some additional changes included due to the above are: • No longer need to pass `displayNameRetriever` throughout • Removed deprecated display name retrieval functions • Removed some duplicate code • Removed some synchronous database query hacks we needed until refactoring had been completed • Refactored mentions to be handled by tags • Refactored and optimised the searching logic (also added some caching) • Refactored the ThreadSettingsViewModel to use the new ObservationBuilder system • Refactored the ThreadPickerViewModel to use the new ObservationBuilder system • Refactored the HomeViewModel to retrieve data using the `ConversationDataHelper` • Refactored the ConversationViewModel to retrieve data using the `ConversationDataHelper` • Fixed an issue where communities weren't getting the correct "joined" timestamp
339 lines
14 KiB
Swift
339 lines
14 KiB
Swift
// Copyright © 2024 Rangeproof Pty Ltd. All rights reserved.
|
|
//
|
|
// stringlint:disable
|
|
|
|
import UIKit
|
|
import Lucide
|
|
|
|
public extension ThemedAttributedString {
|
|
/// 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 = #"<(?<closeTag>\/)?(?<tagName>[A-Za-z0-9]{1,})>"#
|
|
|
|
case bold = "b"
|
|
case italic = "i"
|
|
case underline = "u"
|
|
case strikethrough = "s"
|
|
case primaryTheme = "span"
|
|
case icon = "icon"
|
|
case warningTheme = "warn"
|
|
case dangerTheme = "error"
|
|
case disabledTheme = "disabled"
|
|
case faded = "faded"
|
|
case mention = "mention"
|
|
case userMention = "userMention"
|
|
|
|
// MARK: - Functions
|
|
|
|
static func from(_ stringValue: String) -> (tag: HTMLTag, closeTag: Bool)? {
|
|
let isCloseTag: Bool = stringValue.starts(with: "</")
|
|
|
|
return HTMLTag(
|
|
rawValue: stringValue
|
|
.replacingOccurrences(of: "</", with: "")
|
|
.replacingOccurrences(of: "<", with: "")
|
|
.replacingOccurrences(of: ">", with: "")
|
|
).map { ($0, isCloseTag) }
|
|
}
|
|
|
|
func format(
|
|
with font: UIFont,
|
|
mentionColor: ThemeValue? = nil,
|
|
currentUserMentionImage: UIImage? = nil
|
|
) -> [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 [.themeForegroundColor: ThemeValue.sessionButton_text]
|
|
case .icon: return Lucide.attributes(for: font)
|
|
case .warningTheme: return [.themeForegroundColor: ThemeValue.warning]
|
|
case .dangerTheme: return [.themeForegroundColor: ThemeValue.danger]
|
|
case .disabledTheme: return [.themeForegroundColor: ThemeValue.disabled]
|
|
case .faded: return [.themeAlphaMultiplier: Values.lowOpacity]
|
|
case .mention:
|
|
guard let mentionColor: ThemeValue = mentionColor else { return [:] }
|
|
|
|
return [
|
|
.font: UIFont(
|
|
descriptor: (font.fontDescriptor.withSymbolicTraits(.traitBold) ?? font.fontDescriptor),
|
|
size: 0
|
|
),
|
|
.themeForegroundColor: mentionColor
|
|
]
|
|
|
|
case .userMention:
|
|
guard let currentUserMentionImage: UIImage = currentUserMentionImage else { return [:] }
|
|
|
|
return [.themeCurrentUserMentionImage: currentUserMentionImage]
|
|
}
|
|
}
|
|
}
|
|
|
|
convenience init(
|
|
stringWithHTMLTags: String?,
|
|
font: UIFont,
|
|
attributes: [NSAttributedString.Key: Any] = [:],
|
|
mentionColor: ThemeValue? = nil,
|
|
currentUserMentionImage: UIImage? = nil
|
|
) {
|
|
let standardAttributes: [NSAttributedString.Key: Any] = [.font: font].merging(
|
|
attributes,
|
|
uniquingKeysWith: { _, new in new }
|
|
)
|
|
|
|
guard
|
|
let targetString: String = stringWithHTMLTags,
|
|
let expression: NSRegularExpression = try? NSRegularExpression(
|
|
pattern: HTMLTag.regexPattern,
|
|
options: [.caseInsensitive, .dotMatchesLineSeparators]
|
|
)
|
|
else {
|
|
self.init(string: (stringWithHTMLTags ?? ""), attributes: standardAttributes)
|
|
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: ThemedAttributedString = ThemedAttributedString(
|
|
string: targetString,
|
|
attributes: standardAttributes
|
|
)
|
|
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..<currentMatchEnd]
|
|
let rawStringBetweenMatch: String = attrString[lastMatchEnd..<currentMatchStart]
|
|
partsAndTags.append((rawStringBetweenMatch, Array(openTags.keys)))
|
|
|
|
/// If it's a valid tag then store the location information so we can apply styling later
|
|
if let tagInfo: (tag: HTMLTag, closeTag: Bool) = HTMLTag.from(tagMatch) {
|
|
switch (tagInfo.closeTag, openTags[tagInfo.tag]) {
|
|
/// Add the new opening tag
|
|
case (false, .none): openTags[tagInfo.tag] = 1
|
|
|
|
/// Increment the number of opening tags for the pending format so we can be sure to close them correctly
|
|
case (false, .some(let openCount)): openTags[tagInfo.tag] = (openCount + 1)
|
|
|
|
/// If we had multiple open tags then just decrement the value
|
|
case (true, .some(let openCount)) where openCount > 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, attributes: standardAttributes)
|
|
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: ThemedAttributedString()) { result, next in
|
|
let partAttributes: [NSAttributedString.Key: Any] = next.tags.format(
|
|
with: font,
|
|
mentionColor: mentionColor,
|
|
currentUserMentionImage: currentUserMentionImage
|
|
)
|
|
|
|
result.append(
|
|
ThemedAttributedString(
|
|
string: next.part,
|
|
attributes: standardAttributes
|
|
.merging(partAttributes, uniquingKeysWith: { _, new in new })
|
|
)
|
|
)
|
|
}
|
|
)
|
|
}
|
|
|
|
private subscript(range: Range<Int>) -> String {
|
|
attributedSubstring(from: NSMakeRange(range.lowerBound, (range.upperBound - range.lowerBound))).string
|
|
}
|
|
|
|
private subscript(range: PartialRangeFrom<Int>) -> String {
|
|
let upperBound: Int = self.string.utf16.count
|
|
return attributedSubstring(from: NSMakeRange(range.lowerBound, (upperBound - range.lowerBound))).string
|
|
}
|
|
|
|
private subscript(range: PartialRangeThrough<Int>) -> String {
|
|
attributedSubstring(from: NSMakeRange(0, range.upperBound)).string
|
|
}
|
|
}
|
|
|
|
private extension Collection where Element == ThemedAttributedString.HTMLTag {
|
|
func format(
|
|
with font: UIFont,
|
|
mentionColor: ThemeValue?,
|
|
currentUserMentionImage: UIImage?
|
|
) -> [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 where self.contains(.icon), .icon where self.contains(.bold):
|
|
result[.font] = fontWith(Lucide.font(ofSize: (font.pointSize + 1)), traits: [.traitBold])
|
|
result[.baselineOffset] = Lucide.defaultBaselineOffset
|
|
|
|
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[.themeForegroundColor] = ThemeValue.sessionButton_text
|
|
case .icon:
|
|
result[.font] = fontWith(Lucide.font(ofSize: (font.pointSize + 1)), traits: [])
|
|
result[.baselineOffset] = Lucide.defaultBaselineOffset
|
|
case .warningTheme: result[.themeForegroundColor] = ThemeValue.warning
|
|
case .dangerTheme: result[.themeForegroundColor] = ThemeValue.danger
|
|
case .disabledTheme: result[.themeForegroundColor] = ThemeValue.disabled
|
|
case .faded: result[.themeAlphaMultiplier] = Values.lowOpacity
|
|
case .mention:
|
|
guard let mentionColor: ThemeValue = mentionColor else { return }
|
|
|
|
result[.font] = fontWith(font, traits: [.traitBold])
|
|
result[.themeForegroundColor] = mentionColor
|
|
|
|
case .userMention:
|
|
guard let currentUserMentionImage: UIImage = currentUserMentionImage else { return }
|
|
|
|
result[.themeCurrentUserMentionImage] = currentUserMentionImage
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - FontAccessible
|
|
|
|
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: UIFont!` 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 formatted(
|
|
in view: FontAccessible,
|
|
attributes: [NSAttributedString.Key: Any] = [:],
|
|
mentionColor: ThemeValue? = nil,
|
|
currentUserMentionImage: UIImage? = nil
|
|
) -> ThemedAttributedString {
|
|
return ThemedAttributedString(
|
|
stringWithHTMLTags: self,
|
|
font: (view.fontValue ?? .systemFont(ofSize: 14)),
|
|
attributes: attributes,
|
|
mentionColor: mentionColor,
|
|
currentUserMentionImage: currentUserMentionImage
|
|
)
|
|
}
|
|
|
|
func formatted(
|
|
baseFont: UIFont,
|
|
attributes: [NSAttributedString.Key: Any] = [:],
|
|
mentionColor: ThemeValue? = nil,
|
|
currentUserMentionImage: UIImage? = nil
|
|
) -> ThemedAttributedString {
|
|
return ThemedAttributedString(
|
|
stringWithHTMLTags: self,
|
|
font: baseFont,
|
|
attributes: attributes,
|
|
mentionColor: mentionColor,
|
|
currentUserMentionImage: currentUserMentionImage
|
|
)
|
|
}
|
|
|
|
func formatted() -> ThemedAttributedString {
|
|
return formatted(baseFont: .systemFont(ofSize: Values.smallFontSize))
|
|
}
|
|
|
|
func deformatted() -> String {
|
|
return ThemedAttributedString(
|
|
stringWithHTMLTags: self,
|
|
font: .systemFont(ofSize: 14),
|
|
attributes: [:],
|
|
mentionColor: nil,
|
|
currentUserMentionImage: nil
|
|
).string
|
|
}
|
|
}
|
|
|
|
private extension Optional {
|
|
func defaulting(to value: Wrapped) -> Wrapped {
|
|
return (self ?? value)
|
|
}
|
|
}
|