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

259 lines
9.4 KiB
Swift

// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import Lucide
import SessionUIKit
import SessionMessagingKit
import SessionUtilitiesKit
// MARK: - ConversationTitleViewModel
struct ConversationTitleViewModel: Sendable, Equatable {
let threadVariant: SessionThread.Variant
let displayName: String
let isNoteToSelf: Bool
let isMessageRequest: Bool
let showProBadge: Bool
let isMuted: Bool
let onlyNotifyForMentions: Bool
let userCount: Int?
let disappearingMessagesConfig: DisappearingMessagesConfiguration?
}
// MARK: - ConversationTitleView
final class ConversationTitleView: UIView {
private static let leftInset: CGFloat = 8
private static let leftInsetWithCallButton: CGFloat = 54
private let dependencies: Dependencies
private var oldSize: CGSize = .zero
override var intrinsicContentSize: CGSize {
return UIView.layoutFittingExpandedSize
}
private lazy var labelCarouselViewWidth = labelCarouselView.set(.width, to: 185)
public var currentLabelType: SessionLabelCarouselView.LabelType? {
return self.labelCarouselView.currentLabelType
}
// MARK: - UI Components
private lazy var stackViewLeadingConstraint: NSLayoutConstraint = stackView.pin(.leading, to: .leading, of: self)
private lazy var stackViewTrailingConstraint: NSLayoutConstraint = stackView.pin(.trailing, to: .trailing, of: self)
private lazy var titleLabel: SessionLabelWithProBadge = {
let result: SessionLabelWithProBadge = SessionLabelWithProBadge(
proBadgeSize: .medium,
withStretchingSpacer: false
)
result.accessibilityIdentifier = "Conversation header name"
result.accessibilityLabel = "Conversation header name"
result.isAccessibilityElement = true
result.font = Fonts.Headings.H5
result.themeTextColor = .textPrimary
result.lineBreakMode = .byTruncatingTail
result.isProBadgeHidden = true
return result
}()
private lazy var labelCarouselView: SessionLabelCarouselView = {
let result = SessionLabelCarouselView(using: dependencies)
return result
}()
private lazy var stackView: UIStackView = {
let result = UIStackView(arrangedSubviews: [ titleLabel, labelCarouselView ])
result.axis = .vertical
result.alignment = .center
result.spacing = 2
return result
}()
// MARK: - Initialization
init(using dependencies: Dependencies) {
self.dependencies = dependencies
super.init(frame: .zero)
addSubview(stackView)
stackView.pin(.top, to: .top, of: self)
stackViewLeadingConstraint.isActive = true
stackViewTrailingConstraint.isActive = true
stackView.pin(.bottom, to: .bottom, of: self)
}
deinit {
NotificationCenter.default.removeObserver(self)
}
required init?(coder: NSCoder) {
preconditionFailure("Use init() instead.")
}
// MARK: - Content
override func layoutSubviews() {
super.layoutSubviews()
// There is an annoying issue where pushing seems to update the width of this
// view resulting in the content shifting to the right during
guard self.oldSize != .zero, self.oldSize != bounds.size else {
self.oldSize = bounds.size
return
}
let diff: CGFloat = (bounds.size.width - oldSize.width)
self.stackViewTrailingConstraint.constant = -max(0, diff)
self.oldSize = bounds.size
}
@MainActor public func update(with viewModel: ConversationTitleViewModel) {
let shouldHaveSubtitle: Bool = (
!viewModel.isMessageRequest && (
viewModel.isMuted ||
viewModel.onlyNotifyForMentions ||
viewModel.userCount != nil ||
viewModel.disappearingMessagesConfig?.isEnabled == true
)
)
self.titleLabel.text = viewModel.displayName
self.titleLabel.accessibilityLabel = viewModel.displayName
self.titleLabel.font = (shouldHaveSubtitle ? Fonts.Headings.H6 : Fonts.Headings.H5)
self.titleLabel.isProBadgeHidden = !viewModel.showProBadge
self.labelCarouselView.isHidden = !shouldHaveSubtitle
// Contact threads also have the call button to compensate for
let shouldShowCallButton: Bool = (
!viewModel.isNoteToSelf &&
viewModel.threadVariant == .contact
)
self.stackViewLeadingConstraint.constant = (shouldShowCallButton ?
ConversationTitleView.leftInsetWithCallButton :
ConversationTitleView.leftInset
)
self.stackViewTrailingConstraint.constant = 0
// No need to add themed subtitle content if we aren't adding the subtitle carousel
guard shouldHaveSubtitle else { return }
var labelInfos: [SessionLabelCarouselView.LabelInfo] = []
if viewModel.isMuted {
let notificationSettingsLabelString = ThemedAttributedString(
string: NotificationsUI.mutePrefix.rawValue
)
.appending(string: "notificationsMuted".localized())
.stylingNotificationPrefixesIfNeeded(fontSize: Values.miniFontSize)
labelInfos.append(
SessionLabelCarouselView.LabelInfo(
attributedText: notificationSettingsLabelString,
accessibility: nil, // TODO: Add accessibility
type: .notificationSettings
)
)
}
else if viewModel.onlyNotifyForMentions {
let notificationSettingsLabelString = ThemedAttributedString(
string: NotificationsUI.mentionPrefix.rawValue
)
.appending(string: " ")
.appending(string: "notificationsMentionsOnly".localized())
.stylingNotificationPrefixesIfNeeded(fontSize: Values.miniFontSize)
labelInfos.append(
SessionLabelCarouselView.LabelInfo(
attributedText: notificationSettingsLabelString,
accessibility: nil, // TODO: Add accessibility
type: .notificationSettings
)
)
}
if let userCount: Int = viewModel.userCount {
switch viewModel.threadVariant {
case .contact: break
case .legacyGroup, .group:
labelInfos.append(
SessionLabelCarouselView.LabelInfo(
attributedText: "members"
.putNumber(userCount)
.localizedFormatted(baseFont: .systemFont(ofSize: Values.miniFontSize)),
accessibility: nil, // TODO: Add accessibility
type: .userCount
)
)
case .community:
labelInfos.append(
SessionLabelCarouselView.LabelInfo(
attributedText: "membersActive"
.putNumber(userCount)
.localizedFormatted(baseFont: .systemFont(ofSize: Values.miniFontSize)),
accessibility: nil, // TODO: Add accessibility
type: .userCount
)
)
}
}
if let config = viewModel.disappearingMessagesConfig, config.isEnabled == true {
let imageAttachment = NSTextAttachment()
imageAttachment.image = UIImage(systemName: "timer")?
.withRenderingMode(.alwaysTemplate)
imageAttachment.bounds = CGRect(
x: 0,
y: -2,
width: Values.miniFontSize,
height: Values.miniFontSize
)
labelInfos.append(
SessionLabelCarouselView.LabelInfo(
attributedText: ThemedAttributedString(attachment: imageAttachment)
.appending(string: " ")
.appending(
string: (config.type ?? .unknown)
.localizedState(
durationString: config.durationString
)
),
accessibility: Accessibility(
identifier: "Disappearing messages type and time",
label: "Disappearing messages type and time"
),
type: .disappearingMessageSetting
)
)
}
labelCarouselView.update(
with: labelInfos,
labelSize: CGSize(
width: labelCarouselViewWidth.constant,
height: 12
),
shouldAutoScroll: false
)
labelCarouselView.isHidden = (labelInfos.count == 0)
}
// MARK: - Interaction
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
if self.stackView.frame.contains(point) {
return self.labelCarouselView.scrollView
}
return super.hitTest(point, with: event)
}
}