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

322 lines
14 KiB
Swift

// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import Lucide
import SessionMessagingKit
import SessionUtilitiesKit
import SessionUIKit
extension ContextMenuVC {
struct ExpirationInfo {
let expiresStartedAtMs: Double?
let expiresInSeconds: TimeInterval?
}
struct Action {
let icon: UIImage?
let title: String
let feedback: String?
let expirationInfo: ExpirationInfo?
let themeColor: ThemeValue
let actionType: ActionType
let shouldDismissInfoScreen: Bool
let flipIconForRTL: Bool
let accessibilityLabel: String?
let work: @MainActor ((@MainActor () -> Void)?) -> Void
enum ActionType {
case emoji
case emojiPlus
case dismiss
case generic
}
// MARK: - Initialization
init(
icon: UIImage? = nil,
title: String = "",
feedback: String? = nil,
expirationInfo: ExpirationInfo? = nil,
themeColor: ThemeValue = .textPrimary,
actionType: ActionType = .generic,
shouldDismissInfoScreen: Bool = false,
flipIconForRTL: Bool = false,
accessibilityLabel: String? = nil,
work: @escaping @MainActor ((@MainActor () -> Void)?) -> Void
) {
self.icon = icon
self.title = title
self.feedback = feedback
self.expirationInfo = expirationInfo
self.themeColor = themeColor
self.actionType = actionType
self.shouldDismissInfoScreen = shouldDismissInfoScreen
self.flipIconForRTL = flipIconForRTL
self.accessibilityLabel = accessibilityLabel
self.work = work
}
// MARK: - Actions
static func info(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
return Action(
icon: UIImage(named: "ic_info"),
title: "info".localized(),
accessibilityLabel: "Message info"
) { _ in delegate?.info(cellViewModel) }
}
static func retry(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
return Action(
icon: Lucide.image(icon: .repeat2, size: 24),
title: (cellViewModel.state == .failedToSync ?
"resync".localized() :
"resend".localized()
),
accessibilityLabel: (cellViewModel.state == .failedToSync ? "Resync message" : "Resend message")
) { completion in delegate?.retry(cellViewModel, completion: completion) }
}
static func reply(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
return Action(
icon: Lucide.image(icon: .reply, size: 24),
title: "reply".localized(),
shouldDismissInfoScreen: true,
flipIconForRTL: Dependencies.isRTL,
accessibilityLabel: "Reply to message"
) { completion in delegate?.reply(cellViewModel, completion: completion) }
}
static func copy(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?, forMessageInfoScreen: Bool) -> Action {
return Action(
icon: Lucide.image(icon: .copy, size: 24),
title: forMessageInfoScreen ? "messageCopy".localized() : "copy".localized(),
feedback: "copied".localized(),
accessibilityLabel: "Copy text"
) { completion in delegate?.copy(cellViewModel, completion: completion) }
}
static func copySessionID(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
return Action(
icon: Lucide.image(icon: .copy, size: 24),
title: "accountIDCopy".localized(),
feedback: "copied".localized(),
accessibilityLabel: "Copy Session ID"
) { completion in delegate?.copySessionID(cellViewModel, completion: completion) }
}
static func delete(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
return Action(
icon: Lucide.image(icon: .trash2, size: 24),
title: "delete".localized(),
expirationInfo: ExpirationInfo(
expiresStartedAtMs: cellViewModel.expiresStartedAtMs,
expiresInSeconds: cellViewModel.expiresInSeconds
),
themeColor: .danger,
shouldDismissInfoScreen: true,
accessibilityLabel: "Delete message"
) { completion in delegate?.delete(cellViewModel, completion: completion) }
}
static func save(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
return Action(
icon: Lucide.image(icon: .arrowDownToLine, size: 24),
title: "save".localized(),
feedback: "saved".localized(),
accessibilityLabel: "Save attachment"
) { completion in delegate?.save(cellViewModel, completion: completion) }
}
static func ban(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
return Action(
icon: UIImage(named: "ic_user_round_ban")?.withRenderingMode(.alwaysTemplate),
title: "banUser".localized(),
themeColor: .danger,
accessibilityLabel: "Ban user"
) { completion in delegate?.ban(cellViewModel, completion: completion) }
}
static func banAndDeleteAllMessages(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
return Action(
icon: UIImage(named: "ic_user_round_ban")?.withRenderingMode(.alwaysTemplate),
title: "banDeleteAll".localized(),
themeColor: .danger,
shouldDismissInfoScreen: true,
accessibilityLabel: "Ban user and delete"
) { completion in delegate?.banAndDeleteAllMessages(cellViewModel, completion: completion) }
}
static func react(_ cellViewModel: MessageViewModel, _ emoji: EmojiWithSkinTones, _ delegate: ContextMenuActionDelegate?) -> Action {
return Action(
title: emoji.rawValue,
actionType: .emoji
) { _ in delegate?.react(cellViewModel, with: emoji) }
}
static func emojiPlusButton(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
return Action(
actionType: .emojiPlus,
accessibilityLabel: "Add emoji"
) { _ in delegate?.showFullEmojiKeyboard(cellViewModel) }
}
static func dismiss(_ delegate: ContextMenuActionDelegate?) -> Action {
return Action(
actionType: .dismiss
) { _ in delegate?.contextMenuDismissed() }
}
}
static func viewModelCanReply(_ cellViewModel: MessageViewModel, using dependencies: Dependencies) -> Bool {
return (
cellViewModel.threadVariant != .legacyGroup &&
(
cellViewModel.linkPreview == nil ||
cellViewModel.linkPreview?.variant != .openGroupInvitation
) && (
cellViewModel.variant == .standardIncoming || (
cellViewModel.variant == .standardOutgoing &&
cellViewModel.state != .failed &&
cellViewModel.state != .sending
)
)
)
}
static func actions(
for cellViewModel: MessageViewModel,
threadInfo: ConversationInfoViewModel,
authMethod: AuthenticationMethod,
reactionsSupported: Bool,
recentReactionEmoji: [String],
isUserModeratorOrAdmin: Bool,
forMessageInfoScreen: Bool,
delegate: ContextMenuActionDelegate?,
using dependencies: Dependencies
) -> [Action]? {
switch cellViewModel.variant {
case ._legacyStandardIncomingDeleted, .standardIncomingDeleted, .standardIncomingDeletedLocally,
.standardOutgoingDeleted, .standardOutgoingDeletedLocally, .infoCall,
.infoScreenshotNotification, .infoMediaSavedNotification, .infoLegacyGroupCreated,
.infoLegacyGroupUpdated, .infoLegacyGroupCurrentUserLeft, .infoGroupCurrentUserLeaving,
.infoGroupCurrentUserErrorLeaving, .infoMessageRequestAccepted,
.infoDisappearingMessagesUpdate, .infoGroupInfoInvited, .infoGroupInfoUpdated,
.infoGroupMembersUpdated:
// Let the user delete info messages and unsent messages
return [ Action.delete(cellViewModel, delegate) ]
case .standardOutgoing, .standardIncoming: break
}
let canRetry: Bool = (
cellViewModel.threadVariant != .legacyGroup &&
cellViewModel.variant == .standardOutgoing && (
cellViewModel.state == .failed || (
cellViewModel.threadVariant == .contact &&
cellViewModel.state == .failedToSync
)
)
)
let canCopy: Bool = (
cellViewModel.cellType == .textOnlyMessage || (
(
cellViewModel.cellType == .genericAttachment ||
cellViewModel.cellType == .mediaMessage
) &&
cellViewModel.attachments.count == 1 &&
cellViewModel.attachments.first?.isVisualMedia == true &&
cellViewModel.attachments.first?.isValid == true && (
cellViewModel.attachments.first?.state == .downloaded ||
cellViewModel.attachments.first?.state == .uploaded
)
)
)
let canSave: Bool = {
switch cellViewModel.cellType {
case .mediaMessage:
return cellViewModel.attachments
.filter { attachment in
attachment.isValid &&
attachment.isVisualMedia && (
attachment.state == .downloaded ||
attachment.state == .uploaded
)
}.isEmpty == false
case .audio, .genericAttachment:
return cellViewModel.attachments
.filter { attachment in
attachment.isValid && (
attachment.state == .downloaded ||
attachment.state == .uploaded
)
}.isEmpty == false
default: return false
}
}()
let canCopySessionId: Bool = (
cellViewModel.variant == .standardIncoming &&
cellViewModel.threadVariant != .community &&
!forMessageInfoScreen
)
let canDelete: Bool = ((try? MessageViewModel.DeletionBehaviours.deletionActions(
for: [cellViewModel],
threadInfo: threadInfo,
authMethod: authMethod,
isUserModeratorOrAdmin: isUserModeratorOrAdmin,
using: dependencies
)) != nil)
let canBan: Bool = (
cellViewModel.threadVariant == .community &&
isUserModeratorOrAdmin
)
let recentEmojis: [EmojiWithSkinTones] = {
guard reactionsSupported else { return [] }
return recentReactionEmoji.compactMap { EmojiWithSkinTones(rawValue: $0) }
}()
let generatedActions: [Action] = [
(canRetry ? Action.retry(cellViewModel, delegate) : nil),
(viewModelCanReply(cellViewModel, using: dependencies) ? Action.reply(cellViewModel, delegate) : nil),
(canCopy ? Action.copy(cellViewModel, delegate, forMessageInfoScreen: forMessageInfoScreen) : nil),
(canSave ? Action.save(cellViewModel, delegate) : nil),
(canCopySessionId ? Action.copySessionID(cellViewModel, delegate) : nil),
(canDelete ? Action.delete(cellViewModel, delegate) : nil),
(canBan ? Action.ban(cellViewModel, delegate) : nil),
(canBan ? Action.banAndDeleteAllMessages(cellViewModel, delegate) : nil),
(forMessageInfoScreen ? nil : Action.info(cellViewModel, delegate)),
]
.appending(
contentsOf: (reactionsSupported ? recentEmojis : [])
.map { Action.react(cellViewModel, $0, delegate) }
)
.appending(forMessageInfoScreen ? nil : Action.emojiPlusButton(cellViewModel, delegate))
.compactMap { $0 }
guard !generatedActions.isEmpty else { return [] }
return generatedActions.appending(forMessageInfoScreen ? nil : Action.dismiss(delegate))
}
}
// MARK: - Delegate
protocol ContextMenuActionDelegate {
func info(_ cellViewModel: MessageViewModel)
@MainActor func retry(_ cellViewModel: MessageViewModel, completion: (@MainActor () -> Void)?)
@MainActor func reply(_ cellViewModel: MessageViewModel, completion: (() -> Void)?)
func copy(_ cellViewModel: MessageViewModel, completion: (() -> Void)?)
func copySessionID(_ cellViewModel: MessageViewModel, completion: (() -> Void)?)
func delete(_ cellViewModel: MessageViewModel, completion: (() -> Void)?)
func save(_ cellViewModel: MessageViewModel, completion: (() -> Void)?)
func ban(_ cellViewModel: MessageViewModel, completion: (() -> Void)?)
func banAndDeleteAllMessages(_ cellViewModel: MessageViewModel, completion: (() -> Void)?)
func react(_ cellViewModel: MessageViewModel, with emoji: EmojiWithSkinTones)
func showFullEmojiKeyboard(_ cellViewModel: MessageViewModel)
func contextMenuDismissed()
}