Files
Morgan Pretty ac9816b118 Massive refactors of the SessionThreadViewModel and MessageViewModel
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
2025-12-11 16:47:37 +11:00

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)
}
}