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

233 lines
9.5 KiB
Swift

// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import UIKit
public typealias DisplayNameRetriever = (_ sessionId: String, _ inMessageBody: Bool) -> String?
public enum MentionUtilities {
private static let currentUserCacheKey: String = "Mention.CurrentUser" // stringlint:ignore
private static let pubkeyRegex: NSRegularExpression = try! NSRegularExpression(pattern: "@[0-9a-fA-F]{66}", options: [])
private static let mentionCharacterSet: CharacterSet = CharacterSet(["@"]) // stringlint:ignore
private static let mentionFont: UIFont = .boldSystemFont(ofSize: Values.smallFontSize)
private static let currentUserMentionImageSizeDiff: CGFloat = (Values.smallFontSize / Values.mediumFontSize)
public enum MentionLocation {
case incomingMessage
case outgoingMessage
case incomingQuote
case outgoingQuote
case quoteDraft
case styleFree
}
public static func allPubkeys(in string: String) -> Set<String> {
guard !string.isEmpty else { return [] }
return Set(pubkeyRegex
.matches(in: string, range: NSRange(string.startIndex..., in: string))
.compactMap { match in
Range(match.range, in: string).map { range in
/// Need to remove the leading `@` as this should just retrieve the pubkeys
String(string[range]).trimmingCharacters(in: mentionCharacterSet)
}
})
}
@MainActor public static func generateCurrentUserMentionImage(textColor: ThemeValue) -> UIImage {
return UIView.image(
for: .themedKey(
MentionUtilities.currentUserCacheKey,
themeBackgroundColor: .primary
),
generator: {
HighlightMentionView(
mentionText: "@\("you".localized())", // stringlint:ignore
font: mentionFont,
themeTextColor: .dynamicForInterfaceStyle(light: textColor, dark: .black),
themeBackgroundColor: .primary,
backgroundCornerRadius: (8 * currentUserMentionImageSizeDiff),
backgroundPadding: (3 * currentUserMentionImageSizeDiff)
)
}
)
}
public static func getMentions(
in string: String,
currentUserSessionIds: Set<String>,
displayNameRetriever: DisplayNameRetriever
) -> (String, [(range: NSRange, profileId: String, isCurrentUser: Bool)]) {
/// In `Localization` we manually insert RTL isolate markers to ensure mixked RTL/LTR strings
var workingString: String = string
let hasRLIPrefix: Bool = workingString.hasPrefix("\u{2067}")
let hasPDISuffix: Bool = workingString.hasSuffix("\u{2069}")
if hasRLIPrefix {
workingString = String(workingString.dropFirst())
}
if hasPDISuffix {
workingString = String(workingString.dropLast())
}
var nsString: NSString = (workingString as NSString)
let fullRange = NSRange(location: 0, length: nsString.length)
let resultString: NSMutableString = NSMutableString()
var mentions: [(range: NSRange, profileId: String, isCurrentUser: Bool)] = []
var lastSearchLocation: Int = 0
pubkeyRegex.enumerateMatches(in: workingString, options: [], range: fullRange) { match, _, _ in
guard let match else { return }
/// Append everything before this match
let rangeBefore: NSRange = NSRange(
location: lastSearchLocation,
length: (match.range.location - lastSearchLocation)
)
resultString.append(nsString.substring(with: rangeBefore))
let sessionId: String = String(nsString.substring(with: match.range).dropFirst()) /// Drop the @
let isCurrentUser: Bool = currentUserSessionIds.contains(sessionId)
let displayName: String
if isCurrentUser {
displayName = "you".localized()
}
else if let retrievedName: String = displayNameRetriever(sessionId, true) {
displayName = retrievedName
} else {
/// If we can't get a proper display name then we should just truncate the pubkey
displayName = sessionId.truncated()
}
/// Append the resolved mame
let replacement: String = "@\(displayName)" // stringlint:ignore
let startLocation: Int = resultString.length
resultString.append(replacement)
/// Record the mention
mentions.append((
range: NSRange(location: startLocation, length: (replacement as NSString).length),
profileId: sessionId,
isCurrentUser: isCurrentUser
))
lastSearchLocation = (match.range.location + match.range.length)
}
/// Append any remaining string
if lastSearchLocation < nsString.length {
let remainingRange = NSRange(location: lastSearchLocation, length: nsString.length - lastSearchLocation)
resultString.append(nsString.substring(with: remainingRange))
}
/// Need to add the RTL isolate markers back if we had them
let finalStringRaw: String = (resultString as String)
let finalString: String = (string.containsRTL ?
"\(LocalizationHelper.forceRTLLeading)\(finalStringRaw)\(LocalizationHelper.forceRTLTrailing)" :
finalStringRaw
)
return (finalString, mentions)
}
// stringlint:ignore_contents
public static func taggingMentions(
in string: String,
location: MentionLocation,
currentUserSessionIds: Set<String>,
displayNameRetriever: DisplayNameRetriever
) -> String {
let (mentionReplacedString, mentions) = getMentions(
in: string,
currentUserSessionIds: currentUserSessionIds,
displayNameRetriever: displayNameRetriever
)
guard !mentions.isEmpty else { return mentionReplacedString }
let result: NSMutableString = NSMutableString(string: mentionReplacedString)
/// Iterate in reverse so index ranges remain valid while replacing
for mention in mentions.sorted(by: { $0.range.location > $1.range.location }) {
let mentionText: String = (result as NSString).substring(with: mention.range)
let tag: String = (mention.isCurrentUser && location == .incomingMessage ?
ThemedAttributedString.HTMLTag.userMention.rawValue : /// Only use for incoming
ThemedAttributedString.HTMLTag.mention.rawValue
)
result.replaceCharacters(
in: mention.range,
with: "<\(tag)>\(mentionText)</\(tag)>"
)
}
return (result as String)
}
public static func mentionColor(
textColor: ThemeValue,
location: MentionLocation
) -> ThemeValue {
switch location {
case .incomingMessage: return .dynamicForInterfaceStyle(light: textColor, dark: .primary)
case .outgoingMessage: return .dynamicForInterfaceStyle(light: textColor, dark: .black)
case .outgoingQuote: return .dynamicForInterfaceStyle(light: textColor, dark: .black)
case .incomingQuote: return .dynamicForInterfaceStyle(light: textColor, dark: .primary)
case .quoteDraft, .styleFree: return .dynamicForInterfaceStyle(light: textColor, dark: textColor)
}
}
public static func currentUserMentionImageString(
substring: String,
currentUserMentionImage: UIImage?
) -> NSAttributedString {
guard let currentUserMentionImage else { return NSAttributedString(string: substring) }
/// Set the `accessibilityLabel` to ensure it's still visible to accessibility inspectors
let attachment: NSTextAttachment = NSTextAttachment()
attachment.accessibilityLabel = substring
let offsetY: CGFloat = ((mentionFont.capHeight - currentUserMentionImage.size.height) / 2)
attachment.image = currentUserMentionImage
attachment.bounds = CGRect(
x: 0,
y: offsetY,
width: currentUserMentionImage.size.width,
height: currentUserMentionImage.size.height
)
return NSMutableAttributedString(attachment: attachment)
}
}
public extension MentionUtilities {
static func resolveMentions(
in string: String,
currentUserSessionIds: Set<String>,
displayNameRetriever: DisplayNameRetriever
) -> String {
return MentionUtilities.taggingMentions(
in: string,
location: .outgoingMessage, /// If we are replacing then we don't want to use the image
currentUserSessionIds: currentUserSessionIds,
displayNameRetriever: displayNameRetriever
).deformatted()
}
}
public extension String {
func replacingMentions(
currentUserSessionIds: Set<String>,
displayNameRetriever: DisplayNameRetriever
) -> String {
return MentionUtilities.resolveMentions(
in: self,
currentUserSessionIds: currentUserSessionIds,
displayNameRetriever: displayNameRetriever
)
}
}