From 19cd9d13c5d024b1b08725c2fba80f948611f92e Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Wed, 25 May 2022 18:48:04 +1000 Subject: [PATCH] Cleaned up the ConversationVC query and started plugging in paging Created a generic PagedDatabaseObserver (common logic for conversation & gallery paged database queries and observation) Updated the MediaGallery to use the PagedDatabaseObserver Split the interaction and thread data queries for the conversationVC --- Session.xcodeproj/project.pbxproj | 30 +- .../Context Menu/ContextMenuVC+Action.swift | 94 +- .../Context Menu/ContextMenuVC.swift | 12 +- .../ConversationVC+Interaction.swift | 278 +++-- Session/Conversations/ConversationVC.swift | 161 +-- .../Conversations/ConversationViewModel.swift | 510 ++++++++ .../Conversations/Input View/InputView.swift | 10 +- .../Content Views/LinkPreviewView.swift | 6 +- .../Content Views/MediaPlaceholderView.swift | 10 +- .../Message Cells/InfoMessageCell.swift | 21 +- .../Message Cells/MessageCell.swift | 36 +- .../Models/MessageCellViewModel.swift | 593 +++++++++ .../Message Cells/TypingIndicatorCell.swift | 12 +- .../Message Cells/VisibleMessageCell.swift | 242 ++-- .../MediaGalleryViewModel.swift | 774 ++++-------- .../MediaTileViewController.swift | 26 +- .../Database/Models/Interaction.swift | 4 +- .../Models/InteractionAttachment.swift | 2 +- .../Database/Models/LinkPreview.swift | 4 +- .../Database/Models/Profile.swift | 6 +- .../Database/Models/Quote.swift | 2 +- .../Database/Models/RecipientState.swift | 29 +- .../Database/Models/SessionThread.swift | 2 +- .../MessageReceiver+Handling.swift | 8 +- .../Quotes/QuotedReplyModel.swift | 10 +- .../Typing Indicators/TypingIndicators.swift | 77 +- .../ConversationCellViewModel.swift | 225 +++- .../Shared Models/MessageInputTypes.swift | 9 + .../Database/GRDBStorage.swift | 4 +- .../Types/PagedDatabaseObserver.swift | 1058 +++++++++++++++++ .../Utilities/Database+Utilities.swift | 2 +- ...ption.swift => Dictionary+Utilities.swift} | 0 32 files changed, 3201 insertions(+), 1056 deletions(-) create mode 100644 Session/Conversations/Message Cells/Models/MessageCellViewModel.swift create mode 100644 SessionMessagingKit/Shared Models/MessageInputTypes.swift create mode 100644 SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift rename SessionUtilitiesKit/General/{Dictionary+Description.swift => Dictionary+Utilities.swift} (100%) diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 961263e2e..71ecea48f 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -523,7 +523,7 @@ C3BBE0A72554D4DE0050F1E3 /* Promise+Retrying.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A5D62553860B00C340D1 /* Promise+Retrying.swift */; }; C3BBE0A82554D4DE0050F1E3 /* JSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A5D92553860B00C340D1 /* JSON.swift */; }; C3BBE0A92554D4DE0050F1E3 /* HTTP.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A5BC255385EE00C340D1 /* HTTP.swift */; }; - C3BBE0AA2554D4DE0050F1E3 /* Dictionary+Description.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A5D52553860A00C340D1 /* Dictionary+Description.swift */; }; + C3BBE0AA2554D4DE0050F1E3 /* Dictionary+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A5D52553860A00C340D1 /* Dictionary+Utilities.swift */; }; C3BBE0C72554F1570050F1E3 /* FixedWidthInteger+BigEndian.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3BBE0C62554F1570050F1E3 /* FixedWidthInteger+BigEndian.swift */; }; C3C2A5A3255385C100C340D1 /* SessionSnodeKit.h in Headers */ = {isa = PBXBuildFile; fileRef = C3C2A5A1255385C100C340D1 /* SessionSnodeKit.h */; settings = {ATTRIBUTES = (Public, ); }; }; C3C2A5A7255385C100C340D1 /* SessionSnodeKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A59F255385C100C340D1 /* SessionSnodeKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; @@ -682,6 +682,9 @@ FD705A94278D052B00F16121 /* UITableView+ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD705A93278D052B00F16121 /* UITableView+ReusableView.swift */; }; FD705A98278E9F4D00F16121 /* UIColor+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD705A97278E9F4D00F16121 /* UIColor+Extensions.swift */; }; FD7162DB281B6C440060647B /* TypedTableAlias.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7162DA281B6C440060647B /* TypedTableAlias.swift */; }; + FD848B87283B844B000E298B /* MessageCellViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B86283B844B000E298B /* MessageCellViewModel.swift */; }; + FD848B8B283DC509000E298B /* PagedDatabaseObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B8A283DC509000E298B /* PagedDatabaseObserver.swift */; }; + FD848B8D283E0B26000E298B /* MessageInputTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B8C283E0B26000E298B /* MessageInputTypes.swift */; }; FD859F0027C4691300510D0C /* MockDataGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EFF27C4691300510D0C /* MockDataGenerator.swift */; }; FD88BAD927A7439C00BBC442 /* MessageRequestsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD88BAD827A7439C00BBC442 /* MessageRequestsCell.swift */; }; FD88BADB27A750F200BBC442 /* MessageRequestsMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD88BADA27A750F200BBC442 /* MessageRequestsMigration.swift */; }; @@ -1506,7 +1509,7 @@ C3C2A5D22553860900C340D1 /* String+Trimming.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "String+Trimming.swift"; sourceTree = ""; }; C3C2A5D32553860900C340D1 /* Promise+Delaying.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Promise+Delaying.swift"; sourceTree = ""; }; C3C2A5D42553860A00C340D1 /* Threading.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Threading.swift; sourceTree = ""; }; - C3C2A5D52553860A00C340D1 /* Dictionary+Description.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Dictionary+Description.swift"; sourceTree = ""; }; + C3C2A5D52553860A00C340D1 /* Dictionary+Utilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Dictionary+Utilities.swift"; sourceTree = ""; }; C3C2A5D62553860B00C340D1 /* Promise+Retrying.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Promise+Retrying.swift"; sourceTree = ""; }; C3C2A5D72553860B00C340D1 /* AESGCM.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AESGCM.swift; sourceTree = ""; }; C3C2A5D82553860B00C340D1 /* Data+Utilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Data+Utilities.swift"; sourceTree = ""; }; @@ -1655,6 +1658,9 @@ FD705A93278D052B00F16121 /* UITableView+ReusableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UITableView+ReusableView.swift"; sourceTree = ""; }; FD705A97278E9F4D00F16121 /* UIColor+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+Extensions.swift"; sourceTree = ""; }; FD7162DA281B6C440060647B /* TypedTableAlias.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypedTableAlias.swift; sourceTree = ""; }; + FD848B86283B844B000E298B /* MessageCellViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageCellViewModel.swift; sourceTree = ""; }; + FD848B8A283DC509000E298B /* PagedDatabaseObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PagedDatabaseObserver.swift; sourceTree = ""; }; + FD848B8C283E0B26000E298B /* MessageInputTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageInputTypes.swift; sourceTree = ""; }; FD859EFF27C4691300510D0C /* MockDataGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockDataGenerator.swift; sourceTree = ""; }; FD88BAD827A7439C00BBC442 /* MessageRequestsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsCell.swift; sourceTree = ""; }; FD88BADA27A750F200BBC442 /* MessageRequestsMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsMigration.swift; sourceTree = ""; }; @@ -2107,11 +2113,12 @@ B835247725C38D190089A44F /* Message Cells */ = { isa = PBXGroup; children = ( + FD848B85283B8438000E298B /* Models */, + B8041A7325C8F758003C2166 /* Content Views */, B835247825C38D880089A44F /* MessageCell.swift */, B835249A25C3AB650089A44F /* VisibleMessageCell.swift */, B83524A425C3BA4B0089A44F /* InfoMessageCell.swift */, B8041AA625C90927003C2166 /* TypingIndicatorCell.swift */, - B8041A7325C8F758003C2166 /* Content Views */, ); path = "Message Cells"; sourceTree = ""; @@ -2217,7 +2224,7 @@ C33FDAA8255A57FF00E217F9 /* BuildConfiguration.swift */, B8F5F58225EC94A6003BF8D4 /* Collection+Subscripting.swift */, B8AE75A325A6C6A6001A84D2 /* Data+Trimming.swift */, - C3C2A5D52553860A00C340D1 /* Dictionary+Description.swift */, + C3C2A5D52553860A00C340D1 /* Dictionary+Utilities.swift */, B87EF18026377A1D00124B3C /* Features.swift */, B8BC00BF257D90E30032E807 /* General.swift */, C3C2A5CE2553860700C340D1 /* Logging.swift */, @@ -3374,6 +3381,7 @@ FD17D7B927F51F2100122BE0 /* TargetMigrations.swift */, FD17D7C027F5200100122BE0 /* TypedTableDefinition.swift */, FD7162DA281B6C440060647B /* TypedTableAlias.swift */, + FD848B8A283DC509000E298B /* PagedDatabaseObserver.swift */, ); path = Types; sourceTree = ""; @@ -3443,6 +3451,7 @@ isa = PBXGroup; children = ( FD3E0C83283B5835002A425C /* ConversationCellViewModel.swift */, + FD848B8C283E0B26000E298B /* MessageInputTypes.swift */, ); path = "Shared Models"; sourceTree = ""; @@ -3463,6 +3472,14 @@ path = "Message Requests"; sourceTree = ""; }; + FD848B85283B8438000E298B /* Models */ = { + isa = PBXGroup; + children = ( + FD848B86283B844B000E298B /* MessageCellViewModel.swift */, + ); + path = Models; + sourceTree = ""; + }; FD88BAD727A7438E00BBC442 /* Views */ = { isa = PBXGroup; children = ( @@ -4467,6 +4484,7 @@ FD09797B27FBB25900936362 /* Updatable.swift in Sources */, C32C5A2D256DB849003C73A2 /* LKGroupUtilities.m in Sources */, C3C2ABD22553C6C900C340D1 /* Data+SecureRandom.swift in Sources */, + FD848B8B283DC509000E298B /* PagedDatabaseObserver.swift in Sources */, B8856E09256F1676001CE70E /* UIDevice+featureSupport.swift in Sources */, B8856DEF256F161F001CE70E /* NSString+SSK.m in Sources */, FD09C5E2282212B3000CE219 /* JobDependencies.swift in Sources */, @@ -4486,7 +4504,7 @@ FD17D7BD27F51F6900122BE0 /* GRDB+Notifications.swift in Sources */, FD17D7CA27F546D900122BE0 /* _001_InitialSetupMigration.swift in Sources */, C3D9E4D12567777D0040E4F3 /* OWSMediaUtils.swift in Sources */, - C3BBE0AA2554D4DE0050F1E3 /* Dictionary+Description.swift in Sources */, + C3BBE0AA2554D4DE0050F1E3 /* Dictionary+Utilities.swift in Sources */, C3D9E4DA256778410040E4F3 /* UIImage+OWS.m in Sources */, C32C600F256E07F5003C73A2 /* NSUserDefaults+OWS.m in Sources */, C3D9E35E25675F640040E4F3 /* OWSFileSystem.m in Sources */, @@ -4652,6 +4670,7 @@ C3DB66C3260ACCE6001EFC55 /* OpenGroupPollerV2.swift in Sources */, C3C2A75F2553A3C500C340D1 /* VisibleMessage+LinkPreview.swift in Sources */, C32C5FBB256E0206003C73A2 /* OWSBackgroundTask.m in Sources */, + FD848B8D283E0B26000E298B /* MessageInputTypes.swift in Sources */, B8856CA8256F0F42001CE70E /* OWSBackupFragment.m in Sources */, C32C5C3D256DCBAF003C73A2 /* AppReadiness.m in Sources */, B87EF17126367CF800124B3C /* FileServerAPIV2.swift in Sources */, @@ -4682,6 +4701,7 @@ FDFDE12A282D056B0098B17F /* MediaZoomAnimationController.swift in Sources */, 4C1885D2218F8E1C00B67051 /* PhotoGridViewCell.swift in Sources */, 34D1F0501F7D45A60066283D /* GifPickerCell.swift in Sources */, + FD848B87283B844B000E298B /* MessageCellViewModel.swift in Sources */, C3E5C2FA251DBABB0040DFFC /* EditClosedGroupVC.swift in Sources */, B8783E9E23EB948D00404FB8 /* UILabel+Interaction.swift in Sources */, B893063F2383961A005EAA8E /* ScanQRCodeWrapperVC.swift in Sources */, diff --git a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift index 40ea11916..63ba37af9 100644 --- a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift +++ b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift @@ -8,85 +8,85 @@ extension ContextMenuVC { let title: String let work: () -> Void - static func reply(_ item: ConversationViewModel.Item, _ delegate: ContextMenuActionDelegate?) -> Action { + static func reply(_ cellViewModel: MessageCell.ViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { return Action( icon: UIImage(named: "ic_reply"), title: "context_menu_reply".localized() - ) { delegate?.reply(item) } + ) { delegate?.reply(cellViewModel) } } - static func copy(_ item: ConversationViewModel.Item, _ delegate: ContextMenuActionDelegate?) -> Action { + static func copy(_ cellViewModel: MessageCell.ViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { return Action( icon: UIImage(named: "ic_copy"), title: "copy".localized() - ) { delegate?.copy(item) } + ) { delegate?.copy(cellViewModel) } } - static func copySessionID(_ item: ConversationViewModel.Item, _ delegate: ContextMenuActionDelegate?) -> Action { + static func copySessionID(_ cellViewModel: MessageCell.ViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { return Action( icon: UIImage(named: "ic_copy"), title: "vc_conversation_settings_copy_session_id_button_title".localized() - ) { delegate?.copySessionID(item) } + ) { delegate?.copySessionID(cellViewModel) } } - static func delete(_ item: ConversationViewModel.Item, _ delegate: ContextMenuActionDelegate?) -> Action { + static func delete(_ cellViewModel: MessageCell.ViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { return Action( icon: UIImage(named: "ic_trash"), title: "TXT_DELETE_TITLE".localized() - ) { delegate?.delete(item) } + ) { delegate?.delete(cellViewModel) } } - static func save(_ item: ConversationViewModel.Item, _ delegate: ContextMenuActionDelegate?) -> Action { + static func save(_ cellViewModel: MessageCell.ViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { return Action( icon: UIImage(named: "ic_download"), title: "context_menu_save".localized() - ) { delegate?.save(item) } + ) { delegate?.save(cellViewModel) } } - static func ban(_ item: ConversationViewModel.Item, _ delegate: ContextMenuActionDelegate?) -> Action { + static func ban(_ cellViewModel: MessageCell.ViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { return Action( icon: UIImage(named: "ic_block"), title: "context_menu_ban_user".localized() - ) { delegate?.ban(item) } + ) { delegate?.ban(cellViewModel) } } - static func banAndDeleteAllMessages(_ item: ConversationViewModel.Item, _ delegate: ContextMenuActionDelegate?) -> Action { + static func banAndDeleteAllMessages(_ cellViewModel: MessageCell.ViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { return Action( icon: UIImage(named: "ic_block"), title: "context_menu_ban_and_delete_all".localized() - ) { delegate?.banAndDeleteAllMessages(item) } + ) { delegate?.banAndDeleteAllMessages(cellViewModel) } } } - static func actions(for item: ConversationViewModel.Item, currentUserIsOpenGroupModerator: Bool, delegate: ContextMenuActionDelegate?) -> [Action]? { + static func actions(for cellViewModel: MessageCell.ViewModel, currentUserIsOpenGroupModerator: Bool, delegate: ContextMenuActionDelegate?) -> [Action]? { // No context items for info messages - guard item.interactionVariant == .standardOutgoing || item.interactionVariant == .standardIncoming else { + guard cellViewModel.variant == .standardOutgoing || cellViewModel.variant == .standardIncoming else { return nil } let canReply: Bool = ( - item.interactionVariant != .standardOutgoing || ( - item.state != .failed && - item.state != .sending + cellViewModel.variant != .standardOutgoing || ( + cellViewModel.state != .failed && + cellViewModel.state != .sending ) ) let canCopy: Bool = ( - item.cellType == .textOnlyMessage || ( + cellViewModel.cellType == .textOnlyMessage || ( ( - item.cellType == .genericAttachment || - item.cellType == .mediaMessage + cellViewModel.cellType == .genericAttachment || + cellViewModel.cellType == .mediaMessage ) && - (item.attachments ?? []).count == 1 && - (item.attachments ?? []).first?.isVisualMedia == true && - (item.attachments ?? []).first?.isValid == true && ( - (item.attachments ?? []).first?.state == .downloaded || - (item.attachments ?? []).first?.state == .uploaded + (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 = ( - item.cellType == .mediaMessage && - (item.attachments ?? []) + cellViewModel.cellType == .mediaMessage && + (cellViewModel.attachments ?? []) .filter { attachment in attachment.isValid && attachment.isVisualMedia && ( @@ -96,26 +96,26 @@ extension ContextMenuVC { }.isEmpty == false ) let canCopySessionId: Bool = ( - item.interactionVariant == .standardIncoming && - item.threadVariant != .openGroup + cellViewModel.variant == .standardIncoming && + cellViewModel.threadVariant != .openGroup ) let canDelete: Bool = ( - item.threadVariant != .openGroup || + cellViewModel.threadVariant != .openGroup || currentUserIsOpenGroupModerator ) let canBan: Bool = ( - item.threadVariant == .openGroup && + cellViewModel.threadVariant == .openGroup && currentUserIsOpenGroupModerator ) return [ - (canReply ? Action.reply(item, delegate) : nil), - (canCopy ? Action.copy(item, delegate) : nil), - (canSave ? Action.save(item, delegate) : nil), - (canCopySessionId ? Action.copySessionID(item, delegate) : nil), - (canDelete ? Action.delete(item, delegate) : nil), - (canBan ? Action.ban(item, delegate) : nil), - (canBan ? Action.banAndDeleteAllMessages(item, delegate) : nil) + (canReply ? Action.reply(cellViewModel, delegate) : nil), + (canCopy ? Action.copy(cellViewModel, delegate) : 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) ] .compactMap { $0 } } @@ -124,11 +124,11 @@ extension ContextMenuVC { // MARK: - Delegate protocol ContextMenuActionDelegate { - func reply(_ item: ConversationViewModel.Item) - func copy(_ item: ConversationViewModel.Item) - func copySessionID(_ item: ConversationViewModel.Item) - func delete(_ item: ConversationViewModel.Item) - func save(_ item: ConversationViewModel.Item) - func ban(_ item: ConversationViewModel.Item) - func banAndDeleteAllMessages(_ item: ConversationViewModel.Item) + func reply(_ cellViewModel: MessageCell.ViewModel) + func copy(_ cellViewModel: MessageCell.ViewModel) + func copySessionID(_ cellViewModel: MessageCell.ViewModel) + func delete(_ cellViewModel: MessageCell.ViewModel) + func save(_ cellViewModel: MessageCell.ViewModel) + func ban(_ cellViewModel: MessageCell.ViewModel) + func banAndDeleteAllMessages(_ cellViewModel: MessageCell.ViewModel) } diff --git a/Session/Conversations/Context Menu/ContextMenuVC.swift b/Session/Conversations/Context Menu/ContextMenuVC.swift index 5f4d56cbf..6cf1b0bc5 100644 --- a/Session/Conversations/Context Menu/ContextMenuVC.swift +++ b/Session/Conversations/Context Menu/ContextMenuVC.swift @@ -9,7 +9,7 @@ final class ContextMenuVC: UIViewController { private let snapshot: UIView private let frame: CGRect - private let item: ConversationViewModel.Item + private let cellViewModel: MessageCell.ViewModel private let actions: [Action] private let dismiss: () -> Void @@ -32,7 +32,7 @@ final class ContextMenuVC: UIViewController { result.font = .systemFont(ofSize: Values.verySmallFontSize) result.textColor = (isLightMode ? .black : .white) - if let dateForUI: Date = item.dateForUI { + if let dateForUI: Date = cellViewModel.dateForUI { result.text = DateUtil.formatDate(forDisplay: dateForUI) } @@ -44,13 +44,13 @@ final class ContextMenuVC: UIViewController { init( snapshot: UIView, frame: CGRect, - item: ConversationViewModel.Item, + cellViewModel: MessageCell.ViewModel, actions: [Action], dismiss: @escaping () -> Void ) { self.snapshot = snapshot self.frame = frame - self.item = item + self.cellViewModel = cellViewModel self.actions = actions self.dismiss = dismiss @@ -93,7 +93,7 @@ final class ContextMenuVC: UIViewController { view.addSubview(timestampLabel) timestampLabel.center(.vertical, in: snapshot) - if item.interactionVariant == .standardOutgoing { + if cellViewModel.variant == .standardOutgoing { timestampLabel.pin(.right, to: .left, of: snapshot, withInset: -Values.smallSpacing) } else { @@ -128,7 +128,7 @@ final class ContextMenuVC: UIViewController { menuView.pin(.top, to: .bottom, of: snapshot, withInset: spacing) } - switch item.interactionVariant { + switch cellViewModel.variant { case .standardOutgoing: menuView.pin(.right, to: .right, of: snapshot) case .standardIncoming: menuView.pin(.left, to: .left, of: snapshot) default: break // Should never occur diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index f6b9cad6b..755ba8991 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -21,7 +21,7 @@ extension ConversationVC: { @objc func handleTitleViewTapped() { // Don't take the user to settings for unapproved threads - guard !viewModel.viewData.requiresApproval else { return } + guard viewModel.threadData.threadRequiresApproval == false else { return } openSettings() } @@ -29,11 +29,11 @@ extension ConversationVC: @objc func openSettings() { let settingsVC: OWSConversationSettingsViewController = OWSConversationSettingsViewController() settingsVC.configure( - withThreadId: viewModel.viewData.thread.id, - threadName: viewModel.viewData.threadName, - isClosedGroup: (viewModel.viewData.thread.variant == .closedGroup), - isOpenGroup: (viewModel.viewData.thread.variant == .openGroup), - isNoteToSelf: viewModel.viewData.threadIsNoteToSelf + withThreadId: viewModel.threadData.threadId, + threadName: viewModel.threadData.displayName, + isClosedGroup: (viewModel.threadData.threadVariant == .closedGroup), + isOpenGroup: (viewModel.threadData.threadVariant == .openGroup), + isNoteToSelf: viewModel.threadData.threadIsNoteToSelf ) settingsVC.conversationSettingsViewDelegate = self navigationController?.pushViewController(settingsVC, animated: true, completion: nil) @@ -51,9 +51,9 @@ extension ConversationVC: // MARK: - Blocking @objc func unblock() { - guard self.viewModel.viewData.thread.variant == .contact else { return } + guard self.viewModel.threadData.threadVariant == .contact else { return } - let publicKey: String = self.viewModel.viewData.thread.id + let publicKey: String = self.viewModel.threadData.threadId UIView.animate( withDuration: 0.25, @@ -73,9 +73,9 @@ extension ConversationVC: } func showBlockedModalIfNeeded() -> Bool { - guard viewModel.viewData.threadIsBlocked else { return false } + guard viewModel.threadData.threadIsBlocked == true else { return false } - let blockedModal = BlockedModal(publicKey: viewModel.viewData.thread.id) + let blockedModal = BlockedModal(publicKey: viewModel.threadData.threadId) blockedModal.modalPresentationStyle = .overFullScreen blockedModal.modalTransitionStyle = .crossDissolve present(blockedModal, animated: true, completion: nil) @@ -152,7 +152,7 @@ extension ConversationVC: } func handleLibraryButtonTapped() { - let threadId: String = self.viewModel.viewData.thread.id + let threadId: String = self.viewModel.threadData.threadId requestLibraryPermissionIfNeeded { [weak self] in DispatchQueue.main.async { @@ -175,7 +175,7 @@ extension ConversationVC: SNLog("Proceeding without microphone access. Any recorded video will be silent.") } - let sendMediaNavController = SendMediaNavigationController.showingCameraFirst(threadId: self.viewModel.viewData.thread.id) + let sendMediaNavController = SendMediaNavigationController.showingCameraFirst(threadId: self.viewModel.threadData.threadId) sendMediaNavController.sendMediaNavDelegate = self sendMediaNavController.modalPresentationStyle = .fullScreen @@ -245,7 +245,7 @@ extension ConversationVC: func showAttachmentApprovalDialog(for attachments: [SignalAttachment]) { let navController = AttachmentApprovalViewController.wrappedInNavController( - threadId: self.viewModel.viewData.thread.id, + threadId: self.viewModel.threadData.threadId, attachments: attachments, approvalDelegate: self ) @@ -298,7 +298,7 @@ extension ConversationVC: guard !text.isEmpty else { return } - if text.contains(mnemonic) && !viewModel.viewData.threadIsNoteToSelf && !hasPermissionToSendSeed { + if text.contains(mnemonic) && !viewModel.threadData.threadIsNoteToSelf && !hasPermissionToSendSeed { // Warn the user if they're about to send their seed to someone let modal = SendSeedModal() modal.modalPresentationStyle = .overFullScreen @@ -310,29 +310,34 @@ extension ConversationVC: // Note: 'shouldBeVisible' is set to true the first time a thread is saved so we can // use it to determine if the user is creating a new thread and update the 'isApproved' // flags appropriately - let thread: SessionThread = viewModel.viewData.thread - let oldThreadShouldBeVisible: Bool = thread.shouldBeVisible + let threadId: String = self.viewModel.threadData.threadId + let oldThreadShouldBeVisible: Bool = (self.viewModel.threadData.threadShouldBeVisible == true) let sentTimestampMs: Int64 = Int64(floor((Date().timeIntervalSince1970 * 1000))) let linkPreviewDraft: LinkPreviewDraft? = snInputView.linkPreviewInfo?.draft let quoteModel: QuotedReplyModel? = snInputView.quoteDraftInfo?.model approveMessageRequestIfNeeded( - for: thread, + for: threadId, + threadVariant: self.viewModel.threadData.threadVariant, isNewThread: !oldThreadShouldBeVisible, timestampMs: (sentTimestampMs - 1) // Set 1ms earlier as this is used for sorting ) .done { [weak self] _ in GRDBStorage.shared.writeAsync( updates: { db in + guard let thread: SessionThread = try SessionThread.fetchOne(db, id: threadId) else { + return + } + // Update the thread to be visible _ = try SessionThread - .filter(id: thread.id) + .filter(id: threadId) .updateAll(db, SessionThread.Columns.shouldBeVisible.set(to: true)) // Create the interaction let userPublicKey: String = getUserHexEncodedPublicKey(db) let interaction: Interaction = try Interaction( - threadId: thread.id, + threadId: threadId, authorId: getUserHexEncodedPublicKey(db), variant: .standardOutgoing, body: text, @@ -405,27 +410,32 @@ extension ConversationVC: // Note: 'shouldBeVisible' is set to true the first time a thread is saved so we can // use it to determine if the user is creating a new thread and update the 'isApproved' // flags appropriately - let thread: SessionThread = viewModel.viewData.thread - let oldThreadShouldBeVisible: Bool = thread.shouldBeVisible + let threadId: String = self.viewModel.threadData.threadId + let oldThreadShouldBeVisible: Bool = (self.viewModel.threadData.threadShouldBeVisible == true) let sentTimestampMs: Int64 = Int64(floor((Date().timeIntervalSince1970 * 1000))) approveMessageRequestIfNeeded( - for: thread, + for: threadId, + threadVariant: self.viewModel.threadData.threadVariant, isNewThread: !oldThreadShouldBeVisible, timestampMs: (sentTimestampMs - 1) // Set 1ms earlier as this is used for sorting ) .done { [weak self] _ in GRDBStorage.shared.writeAsync( updates: { db in + guard let thread: SessionThread = try SessionThread.fetchOne(db, id: threadId) else { + return + } + // Update the thread to be visible _ = try SessionThread - .filter(id: thread.id) + .filter(id: threadId) .updateAll(db, SessionThread.Columns.shouldBeVisible.set(to: true)) // Create the interaction let userPublicKey: String = getUserHexEncodedPublicKey(db) let interaction: Interaction = try Interaction( - threadId: thread.id, + threadId: threadId, authorId: getUserHexEncodedPublicKey(db), variant: .standardOutgoing, body: text, @@ -473,13 +483,13 @@ extension ConversationVC: AudioServicesPlaySystemSound(soundID) } - let thread: SessionThread = self.viewModel.viewData.thread + let threadId: String = self.viewModel.threadData.threadId GRDBStorage.shared.writeAsync { db in - TypingIndicators.didStopTyping(db, in: thread, direction: .outgoing) + TypingIndicators.didStopTyping(db, threadId: threadId, direction: .outgoing) _ = try SessionThread - .filter(id: thread.id) + .filter(id: threadId) .updateAll(db, SessionThread.Columns.messageDraft.set(to: "")) } } @@ -497,12 +507,16 @@ extension ConversationVC: let newText: String = (inputTextView.text ?? "") if !newText.isEmpty { - let thread: SessionThread = self.viewModel.viewData.thread + let threadId: String = self.viewModel.threadData.threadId + let threadVariant: SessionThread.Variant = self.viewModel.threadData.threadVariant + let threadIsMessageRequest: Bool = (self.viewModel.threadData.threadIsMessageRequest == true) GRDBStorage.shared.writeAsync { db in TypingIndicators.didStartTyping( db, - in: thread, + threadId: threadId, + threadVariant: threadVariant, + threadIsMessageRequest: threadIsMessageRequest, direction: .outgoing, timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000)) ) @@ -521,7 +535,7 @@ extension ConversationVC: let attachment = SignalAttachment.attachment(dataSource: dataSource, dataUTI: kUTTypeJPEG as String, imageQuality: .medium) let approvalVC = AttachmentApprovalViewController.wrappedInNavController( - threadId: self.viewModel.viewData.thread.id, + threadId: self.viewModel.threadData.threadId, attachments: [ attachment ], approvalDelegate: self ) @@ -539,7 +553,7 @@ extension ConversationVC: let newText: String = snInputView.text.replacingCharacters( in: currentMentionStartIndex..., - with: "@\(mentionInfo.profile.displayName(for: self.viewModel.viewData.thread.variant)) " + with: "@\(mentionInfo.profile.displayName(for: self.viewModel.threadData.threadVariant)) " ) snInputView.text = newText @@ -547,7 +561,7 @@ extension ConversationVC: snInputView.hideMentionsUI() mentions = mentions.filter { mentionInfo -> Bool in - newText.contains(mentionInfo.profile.displayName(for: self.viewModel.viewData.thread.variant)) + newText.contains(mentionInfo.profile.displayName(for: self.viewModel.threadData.threadVariant)) } } @@ -614,20 +628,20 @@ extension ConversationVC: // MARK: MessageCellDelegate - func handleItemLongPressed(_ item: ConversationViewModel.Item) { + func handleItemLongPressed(_ cellViewModel: MessageCell.ViewModel) { // Show the context menu if applicable guard let keyWindow: UIWindow = UIApplication.shared.keyWindow, - let index = viewModel.viewData.items.firstIndex(of: item), + let index = viewModel.interactionData.firstIndex(of: cellViewModel), let cell = tableView.cellForRow(at: IndexPath(row: index, section: 0)) as? VisibleMessageCell, let snapshot = cell.bubbleView.snapshotView(afterScreenUpdates: false), contextMenuWindow == nil, let actions: [ContextMenuVC.Action] = ContextMenuVC.actions( - for: item, + for: cellViewModel, currentUserIsOpenGroupModerator: OpenGroupAPIV2.isUserModerator( - self.viewModel.viewData.userPublicKey, - for: self.viewModel.viewData.openGroupRoom, - on: self.viewModel.viewData.openGroupServer + self.viewModel.threadData.currentUserPublicKey, + for: self.viewModel.threadData.openGroupRoom, + on: self.viewModel.threadData.openGroupServer ), delegate: self ) @@ -638,7 +652,7 @@ extension ConversationVC: self.contextMenuVC = ContextMenuVC( snapshot: snapshot, frame: cell.convert(cell.bubbleView.frame, to: keyWindow), - item: item, + cellViewModel: cellViewModel, actions: actions ) { [weak self] in self?.contextMenuWindow?.isHidden = true @@ -657,16 +671,16 @@ extension ConversationVC: self.contextMenuWindow?.makeKeyAndVisible() } - func handleItemTapped(_ item: ConversationViewModel.Item, gestureRecognizer: UITapGestureRecognizer) { - guard item.interactionVariant != .standardOutgoing || item.state != .failed else { + func handleItemTapped(_ cellViewModel: MessageCell.ViewModel, gestureRecognizer: UITapGestureRecognizer) { + guard cellViewModel.variant != .standardOutgoing || cellViewModel.state != .failed else { // Show the failed message sheet - showFailedMessageSheet(for: item) + showFailedMessageSheet(for: cellViewModel) return } // If it's an incoming media message and the thread isn't trusted then show the placeholder view - if item.cellType != .textOnlyMessage && item.interactionVariant == .standardIncoming && !item.isThreadTrusted { - let modal = DownloadAttachmentModal(profile: item.profile) + if cellViewModel.cellType != .textOnlyMessage && cellViewModel.variant == .standardIncoming && !cellViewModel.threadIsTrusted { + let modal = DownloadAttachmentModal(profile: cellViewModel.profile) modal.modalPresentationStyle = .overFullScreen modal.modalTransitionStyle = .crossDissolve @@ -674,12 +688,12 @@ extension ConversationVC: return } - switch item.cellType { - case .audio: viewModel.playOrPauseAudio(for: item) + switch cellViewModel.cellType { + case .audio: viewModel.playOrPauseAudio(for: cellViewModel) case .mediaMessage: guard - let index = self.viewModel.viewData.items.firstIndex(where: { $0.interactionId == item.interactionId }), + let index = self.viewModel.interactionData.firstIndex(where: { $0.id == cellViewModel.id }), let cell = tableView.cellForRow(at: IndexPath(row: index, section: 0)) as? VisibleMessageCell, let albumView: MediaAlbumView = cell.albumView else { return } @@ -702,9 +716,9 @@ extension ConversationVC: default: let viewController: UIViewController? = MediaGalleryViewModel.createDetailViewController( - for: self.viewModel.viewData.thread.id, - threadVariant: self.viewModel.viewData.thread.variant, - interactionId: item.interactionId, + for: self.viewModel.threadData.threadId, + threadVariant: self.viewModel.threadData.threadVariant, + interactionId: cellViewModel.id, selectedAttachmentId: mediaView.attachment.id, options: [ .sliderEnabled, .showAllMediaButton ] ) @@ -718,7 +732,7 @@ extension ConversationVC: self.resignFirstResponder() /// Delay the actual presentation to give the 'resignFirstResponder' call the chance to complete - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(200)) { [weak self] in + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250)) { [weak self] in /// Lock the contentOffset of the tableView so the transition doesn't look buggy self?.tableView.lockContentOffset = true @@ -733,7 +747,7 @@ extension ConversationVC: case .genericAttachment: guard - let attachment: Attachment = item.attachments?.first, + let attachment: Attachment = cellViewModel.attachments?.first, let originalFilePath: String = attachment.originalFilePath else { return } @@ -766,19 +780,18 @@ extension ConversationVC: joinOpenGroup(name: name, url: url) } default: break - } } } - func handleItemDoubleTapped(_ item: ConversationViewModel.Item) { - switch item.cellType { + func handleItemDoubleTapped(_ cellViewModel: MessageCell.ViewModel) { + switch cellViewModel.cellType { // The user can double tap a voice message when it's playing to speed it up - case .audio: self.viewModel.speedUpAudio(for: item) + case .audio: self.viewModel.speedUpAudio(for: cellViewModel) default: break } } - func handleItemSwiped(_ item: ConversationViewModel.Item, state: SwipeState) { + func handleItemSwiped(_ cellViewModel: MessageCell.ViewModel, state: SwipeState) { switch state { case .began: tableView.isScrollEnabled = false case .ended, .cancelled: tableView.isScrollEnabled = true @@ -809,8 +822,8 @@ extension ConversationVC: self.presentAlert(alertVC) } - func handleReplyButtonTapped(for item: ConversationViewModel.Item) { - reply(item) + func handleReplyButtonTapped(for cellViewModel: MessageCell.ViewModel) { + reply(cellViewModel) } func showUserDetails(for profile: Profile) { @@ -824,21 +837,22 @@ extension ConversationVC: // MARK: --action handling - func showFailedMessageSheet(for item: ConversationViewModel.Item) { - let sheet = UIAlertController(title: item.mostRecentFailureText, message: nil, preferredStyle: .actionSheet) + func showFailedMessageSheet(for cellViewModel: MessageCell.ViewModel) { + let sheet = UIAlertController(title: cellViewModel.mostRecentFailureText, message: nil, preferredStyle: .actionSheet) sheet.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil)) sheet.addAction(UIAlertAction(title: "Delete", style: .destructive, handler: { _ in GRDBStorage.shared.writeAsync { db in try Interaction - .filter(id: item.interactionId) + .filter(id: cellViewModel.id) .deleteAll(db) } })) sheet.addAction(UIAlertAction(title: "Resend", style: .default, handler: { _ in GRDBStorage.shared.writeAsync { [weak self] db in guard - let interaction: Interaction = try? Interaction.fetchOne(db, id: item.interactionId), - let thread: SessionThread = self?.viewModel.viewData.thread + let threadId: String = self?.viewModel.threadData.threadId, + let interaction: Interaction = try? Interaction.fetchOne(db, id: cellViewModel.id), + let thread: SessionThread = try SessionThread.fetchOne(db, id: threadId) else { return } try MessageSender.send( db, @@ -850,7 +864,7 @@ extension ConversationVC: // HACK: Extracting this info from the error string is pretty dodgy let prefix: String = "HTTP request failed at destination (Service node " - if let mostRecentFailureText: String = item.mostRecentFailureText, mostRecentFailureText.hasPrefix(prefix) { + if let mostRecentFailureText: String = cellViewModel.mostRecentFailureText, mostRecentFailureText.hasPrefix(prefix) { let rest = mostRecentFailureText.substring(from: prefix.count) if let index = rest.firstIndex(of: ")") { @@ -876,37 +890,37 @@ extension ConversationVC: // MARK: - ContextMenuActionDelegate - func reply(_ item: ConversationViewModel.Item) { + func reply(_ cellViewModel: MessageCell.ViewModel) { let maybeQuoteDraft: QuotedReplyModel? = QuotedReplyModel.quotedReplyForSending( - threadId: self.viewModel.viewData.thread.id, - authorId: item.authorId, - variant: item.interactionVariant, - body: item.body, - timestampMs: item.timestampMs, - attachments: item.attachments, - linkPreview: item.linkPreview + threadId: self.viewModel.threadData.threadId, + authorId: cellViewModel.authorId, + variant: cellViewModel.variant, + body: cellViewModel.body, + timestampMs: cellViewModel.timestampMs, + attachments: cellViewModel.attachments, + linkPreviewAttachment: cellViewModel.linkPreviewAttachment ) guard let quoteDraft: QuotedReplyModel = maybeQuoteDraft else { return } snInputView.quoteDraftInfo = ( model: quoteDraft, - isOutgoing: (item.interactionVariant == .standardOutgoing) + isOutgoing: (cellViewModel.variant == .standardOutgoing) ) snInputView.becomeFirstResponder() } - func copy(_ item: ConversationViewModel.Item) { - switch item.cellType { + func copy(_ cellViewModel: MessageCell.ViewModel) { + switch cellViewModel.cellType { case .typingIndicator: break case .textOnlyMessage: - UIPasteboard.general.string = item.body + UIPasteboard.general.string = cellViewModel.body case .audio, .genericAttachment, .mediaMessage: guard - item.attachments?.count == 1, - let attachment: Attachment = item.attachments?.first, + cellViewModel.attachments?.count == 1, + let attachment: Attachment = cellViewModel.attachments?.first, attachment.isValid, ( attachment.state == .downloaded || @@ -921,22 +935,22 @@ extension ConversationVC: } } - func copySessionID(_ item: ConversationViewModel.Item) { - guard item.interactionVariant == .standardIncoming || item.interactionVariant == .standardIncomingDeleted else { + func copySessionID(_ cellViewModel: MessageCell.ViewModel) { + guard cellViewModel.variant == .standardIncoming || cellViewModel.variant == .standardIncomingDeleted else { return } - UIPasteboard.general.string = item.authorId + UIPasteboard.general.string = cellViewModel.authorId } - func delete(_ item: ConversationViewModel.Item) { + func delete(_ cellViewModel: MessageCell.ViewModel) { // Only allow deletion on incoming and outgoing messages - guard item.interactionVariant == .standardIncoming || item.interactionVariant == .standardOutgoing else { + guard cellViewModel.variant == .standardIncoming || cellViewModel.variant == .standardOutgoing else { return } - let thread: SessionThread = self.viewModel.viewData.thread - let threadName: String = self.viewModel.viewData.threadName + let threadId: String = self.viewModel.threadData.threadId + let threadName: String = self.viewModel.threadData.displayName let userPublicKey: String = getUserHexEncodedPublicKey() // Remote deletion logic @@ -954,7 +968,7 @@ extension ConversationVC: // Delete the interaction (and associated data) from the database GRDBStorage.shared.writeAsync { db in _ = try Interaction - .filter(id: item.interactionId) + .filter(id: cellViewModel.id) .deleteAll(db) } } @@ -971,7 +985,7 @@ extension ConversationVC: } // How we delete the message differs depending on the type of thread - switch item.threadVariant { + switch cellViewModel.threadVariant { // Handle open group messages the old way case .openGroup: // If it's an incoming message the user must have moderator status @@ -979,17 +993,17 @@ extension ConversationVC: ( try Interaction .select(.openGroupServerMessageId) - .filter(id: item.interactionId) + .filter(id: cellViewModel.id) .asRequest(of: Int64.self) .fetchOne(db), - try OpenGroup.fetchOne(db, id: thread.id) + try OpenGroup.fetchOne(db, id: threadId) ) } guard let openGroup: OpenGroup = result?.openGroup, let openGroupServerMessageId: Int64 = result?.openGroupServerMessageId, ( - item.interactionVariant != .standardIncoming || + cellViewModel.variant != .standardIncoming || OpenGroupAPIV2.isUserModerator(userPublicKey, for: openGroup.room, on: openGroup.server) ) else { return } @@ -1010,23 +1024,23 @@ extension ConversationVC: let serverHash: String? = GRDBStorage.shared.read { db -> String? in try Interaction .select(.serverHash) - .filter(id: item.interactionId) + .filter(id: cellViewModel.id) .asRequest(of: String.self) .fetchOne(db) } let unsendRequest: UnsendRequest = UnsendRequest( - timestamp: UInt64(item.timestampMs), - author: (item.interactionVariant == .standardOutgoing ? + timestamp: UInt64(cellViewModel.timestampMs), + author: (cellViewModel.variant == .standardOutgoing ? userPublicKey : - item.authorId + cellViewModel.authorId ) ) // For incoming interactions or interactions with no serverHash just delete them locally - guard item.interactionVariant == .standardOutgoing, let serverHash: String = serverHash else { + guard cellViewModel.variant == .standardOutgoing, let serverHash: String = serverHash else { GRDBStorage.shared.writeAsync { db in _ = try Interaction - .filter(id: item.interactionId) + .filter(id: cellViewModel.id) .deleteAll(db) // No need to send the unsendRequest if there is no serverHash (ie. the message @@ -1037,7 +1051,7 @@ extension ConversationVC: .send( db, message: unsendRequest, - threadId: thread.id, + threadId: threadId, interactionId: nil, to: .contact(publicKey: userPublicKey) ) @@ -1049,14 +1063,14 @@ extension ConversationVC: alertVC.addAction(UIAlertAction(title: "delete_message_for_me".localized(), style: .destructive) { [weak self] _ in GRDBStorage.shared.writeAsync { db in _ = try Interaction - .filter(id: item.interactionId) + .filter(id: cellViewModel.id) .deleteAll(db) MessageSender .send( db, message: unsendRequest, - threadId: thread.id, + threadId: threadId, interactionId: nil, to: .contact(publicKey: userPublicKey) ) @@ -1065,7 +1079,7 @@ extension ConversationVC: }) alertVC.addAction(UIAlertAction( - title: (item.threadVariant == .closedGroup ? + title: (cellViewModel.threadVariant == .closedGroup ? "delete_message_for_everyone".localized() : String(format: "delete_message_for_me_and_recipient".localized(), threadName) ), @@ -1075,12 +1089,16 @@ extension ConversationVC: from: self, request: SnodeAPI .deleteMessage( - publicKey: thread.id, + publicKey: threadId, serverHashes: [serverHash] ) .map { _ in () } ) { [weak self] in GRDBStorage.shared.writeAsync { db in + guard let thread: SessionThread = try SessionThread.fetchOne(db, id: threadId) else { + return + } + try MessageSender .send( db, @@ -1104,10 +1122,10 @@ extension ConversationVC: } } - func save(_ item: ConversationViewModel.Item) { - guard item.cellType == .mediaMessage else { return } + func save(_ cellViewModel: MessageCell.ViewModel) { + guard cellViewModel.cellType == .mediaMessage else { return } - let mediaAttachments: [(Attachment, String)] = (item.attachments ?? []) + let mediaAttachments: [(Attachment, String)] = (cellViewModel.attachments ?? []) .filter { attachment in attachment.isValid && attachment.isVisualMedia && ( @@ -1142,17 +1160,19 @@ extension ConversationVC: } // Send a 'media saved' notification if needed - guard self.viewModel.viewData.thread.variant == .contact, item.interactionVariant == .standardIncoming else { + guard self.viewModel.threadData.threadVariant == .contact, cellViewModel.variant == .standardIncoming else { return } - let thread: SessionThread = self.viewModel.viewData.thread + let threadId: String = self.viewModel.threadData.threadId GRDBStorage.shared.writeAsync { db in + guard let thread: SessionThread = try SessionThread.fetchOne(db, id: threadId) else { return } + try MessageSender.send( db, message: DataExtractionNotification( - kind: .mediaSaved(timestamp: UInt64(item.timestampMs)) + kind: .mediaSaved(timestamp: UInt64(cellViewModel.timestampMs)) ), interactionId: nil, in: thread @@ -1160,10 +1180,10 @@ extension ConversationVC: } } - func ban(_ item: ConversationViewModel.Item) { - guard item.threadVariant == .openGroup else { return } + func ban(_ cellViewModel: MessageCell.ViewModel) { + guard cellViewModel.threadVariant == .openGroup else { return } - let threadId: String = self.viewModel.viewData.thread.id + let threadId: String = self.viewModel.threadData.threadId let alert: UIAlertController = UIAlertController( title: "Session", message: "This will ban the selected user from this room. It won't ban them from other rooms.", @@ -1175,7 +1195,7 @@ extension ConversationVC: } OpenGroupAPIV2 - .ban(item.authorId, from: openGroup.room, on: openGroup.server) + .ban(cellViewModel.authorId, from: openGroup.room, on: openGroup.server) .retainUntilComplete() })) alert.addAction(UIAlertAction(title: "Cancel", style: .default, handler: nil)) @@ -1183,10 +1203,10 @@ extension ConversationVC: present(alert, animated: true, completion: nil) } - func banAndDeleteAllMessages(_ item: ConversationViewModel.Item) { - guard item.threadVariant == .openGroup else { return } + func banAndDeleteAllMessages(_ cellViewModel: MessageCell.ViewModel) { + guard cellViewModel.threadVariant == .openGroup else { return } - let threadId: String = self.viewModel.viewData.thread.id + let threadId: String = self.viewModel.threadData.threadId let alert: UIAlertController = UIAlertController( title: "Session", message: "This will ban the selected user from this room and delete all messages sent by them. It won't ban them from other rooms or delete the messages they sent there.", @@ -1198,7 +1218,7 @@ extension ConversationVC: } OpenGroupAPIV2 - .banAndDeleteAllMessages(item.authorId, from: openGroup.room, on: openGroup.server) + .banAndDeleteAllMessages(cellViewModel.authorId, from: openGroup.room, on: openGroup.server) .retainUntilComplete() })) alert.addAction(UIAlertAction(title: "Cancel", style: .default, handler: nil)) @@ -1451,18 +1471,25 @@ extension ConversationVC: UIDocumentInteractionControllerDelegate { extension ConversationVC { fileprivate func approveMessageRequestIfNeeded( - for thread: SessionThread?, + for threadId: String, + threadVariant: SessionThread.Variant, isNewThread: Bool, timestampMs: Int64 ) -> Promise { - guard let thread: SessionThread = thread, thread.variant == .contact else { return Promise.value(()) } + guard threadVariant == .contact else { return Promise.value(()) } // If the contact doesn't exist then we should create it so we can store the 'isApproved' state // (it'll be updated with correct profile info if they accept the message request so this // shouldn't cause weird behaviours) guard - let contact: Contact = GRDBStorage.shared.read({ db in Contact.fetchOrCreate(db, id: thread.id) }), - !contact.isApproved + let approvalData: (contact: Contact, thread: SessionThread?) = GRDBStorage.shared.read({ db in + return ( + Contact.fetchOrCreate(db, id: threadId), + try SessionThread.fetchOne(db, id: threadId) + ) + }), + let thread: SessionThread = approvalData.thread, + !approvalData.contact.isApproved else { return Promise.value(()) } @@ -1507,10 +1534,10 @@ extension ConversationVC { // Default 'didApproveMe' to true for the person approving the message request GRDBStorage.shared.writeAsync( updates: { db in - try contact + try approvalData.contact .with( isApproved: true, - didApproveMe: .update(contact.didApproveMe || !isNewThread) + didApproveMe: .update(approvalData.contact.didApproveMe || !isNewThread) ) .save(db) @@ -1559,7 +1586,8 @@ extension ConversationVC { @objc func acceptMessageRequest() { self.approveMessageRequestIfNeeded( - for: self.viewModel.viewData.thread, + for: self.viewModel.threadData.threadId, + threadVariant: self.viewModel.threadData.threadVariant, isNewThread: false, timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000)) ) @@ -1577,9 +1605,9 @@ extension ConversationVC { } @objc func deleteMessageRequest() { - guard self.viewModel.viewData.thread.variant == .contact else { return } + guard self.viewModel.threadData.threadVariant == .contact else { return } - let threadId: String = self.viewModel.viewData.thread.id + let threadId: String = self.viewModel.threadData.threadId let alertVC: UIAlertController = UIAlertController( title: "MESSAGE_REQUESTS_DELETE_CONFIRMATION_ACTON".localized(), message: nil, diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index 37250395b..69c5b293e 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -62,8 +62,8 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers override var inputAccessoryView: UIView? { guard - viewModel.viewData.thread.variant != .closedGroup || - viewModel.viewData.isClosedGroupMember + viewModel.threadData.threadVariant != .closedGroup || + viewModel.threadData.currentUserIsClosedGroupMember == true else { return nil } return (isShowingSearchUI ? searchController.resultsBar : snInputView) @@ -150,7 +150,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers }() lazy var snInputView: InputView = InputView( - threadVariant: viewModel.viewData.thread.variant, + threadVariant: self.viewModel.threadData.threadVariant, delegate: self ) @@ -176,7 +176,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers lazy var blockedBanner: InfoBanner = { let result: InfoBanner = InfoBanner( - message: viewModel.blockedBannerMessage, + message: self.viewModel.blockedBannerMessage, backgroundColor: Colors.destructive ) let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(unblock)) @@ -203,7 +203,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers lazy var messageRequestView: UIView = { let result: UIView = UIView() result.translatesAutoresizingMaskIntoConstraints = false - result.isHidden = !viewModel.viewData.threadIsMessageRequest + result.isHidden = (self.viewModel.threadData.threadIsMessageRequest == false) result.setGradient(Gradients.defaultBackground) return result @@ -330,19 +330,19 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers navigationItem.titleView = titleView titleView.update( - with: viewModel.viewData.threadName, - mutedUntilTimestamp: viewModel.viewData.thread.mutedUntilTimestamp, - onlyNotifyForMentions: viewModel.viewData.thread.onlyNotifyForMentions, - userCount: viewModel.viewData.userCount + with: viewModel.threadData.displayName, + mutedUntilTimestamp: viewModel.threadData.threadMutedUntilTimestamp, + onlyNotifyForMentions: (viewModel.threadData.threadOnlyNotifyForMentions == true), + userCount: viewModel.threadData.userCount ) - updateNavBarButtons(viewData: viewModel.viewData) + updateNavBarButtons(threadData: viewModel.threadData) // Constraints view.addSubview(tableView) tableView.pin(to: view) // Blocked banner - addOrRemoveBlockedBanner(threadIsBlocked: viewModel.viewData.threadIsBlocked) + addOrRemoveBlockedBanner(threadIsBlocked: (viewModel.threadData.threadIsBlocked == true)) // Message requests view & scroll to bottom view.addSubview(scrollButton) @@ -359,8 +359,8 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers self.scrollButtonBottomConstraint = scrollButton.pin(.bottom, to: .bottom, of: view, withInset: -16) self.scrollButtonBottomConstraint?.isActive = false // Note: Need to disable this to avoid a conflict with the other bottom constraint self.scrollButtonMessageRequestsBottomConstraint = scrollButton.pin(.bottom, to: .top, of: messageRequestView, withInset: -16) - self.scrollButtonMessageRequestsBottomConstraint?.isActive = viewModel.viewData.threadIsMessageRequest - self.scrollButtonBottomConstraint?.isActive = !viewModel.viewData.threadIsMessageRequest + self.scrollButtonMessageRequestsBottomConstraint?.isActive = (viewModel.threadData.threadIsMessageRequest == true) + self.scrollButtonBottomConstraint?.isActive = (viewModel.threadData.threadIsMessageRequest == false) messageRequestDescriptionLabel.pin(.top, to: .top, of: messageRequestView, withInset: 10) messageRequestDescriptionLabel.pin(.left, to: .left, of: messageRequestView, withInset: 40) @@ -392,7 +392,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers unreadCountView.pin(.trailing, to: .trailing, of: unreadCountLabel, withInset: 4) unreadCountView.centerYAnchor.constraint(equalTo: scrollButton.topAnchor).isActive = true unreadCountView.center(.horizontal, in: scrollButton) - updateUnreadCountView(unreadCount: viewModel.viewData.unreadCount) + updateUnreadCountView(unreadCount: viewModel.threadData.threadUnreadCount) // Notifications NotificationCenter.default.addObserver( @@ -420,12 +420,12 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers ) // Draft - if let draft: String = viewModel.viewData.thread.messageDraft, !draft.isEmpty { + if let draft: String = viewModel.threadData.threadMessageDraft, !draft.isEmpty { snInputView.text = draft } // Update the input state - snInputView.setEnabledMessageTypes(viewModel.viewData.enabledMessageTypes, message: nil) + snInputView.setEnabledMessageTypes(viewModel.threadData.enabledMessageTypes, message: nil) } @@ -441,7 +441,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers if let focusedInteractionId: Int64 = self.viewModel.focusedInteractionId { self.scrollToInteraction(with: focusedInteractionId, isAnimated: false, highlighted: true) } - else if let firstUnreadInteractionId: Int64 = self.viewModel.viewData.firstUnreadInteractionId { + else if let firstUnreadInteractionId: Int64 = self.viewModel.threadData.threadFirstUnreadInteractionId { self.scrollToInteraction(with: firstUnreadInteractionId, position: .top, isAnimated: false) self.unreadCountView.alpha = self.scrollButton.alpha } @@ -478,8 +478,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) - // Stop observing database changes - dataChangeObservable?.cancel() + stopObservingChanges() viewModel.updateDraft(to: snInputView.text) inputAccessoryView?.resignFirstResponder() } @@ -496,8 +495,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers } @objc func applicationDidResignActive(_ notification: Notification) { - // Stop observing database changes - dataChangeObservable?.cancel() + stopObservingChanges() } // MARK: - Updating @@ -505,84 +503,104 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers private func startObservingChanges() { // Start observing for data changes dataChangeObservable = GRDBStorage.shared.start( - viewModel.observableViewData, - onError: { error in - }, - onChange: { [weak self] viewData in + viewModel.observableThreadData, + onError: { _ in }, + onChange: { [weak self] maybeThreadData in + guard let threadData: ConversationCell.ViewModel = maybeThreadData else { return } + // The default scheduler emits changes on the main thread - self?.handleUpdates(viewData) + self?.handleThreadUpdates(threadData) } ) + + self.viewModel.onInteractionChange = { [weak self] updatedInteractionData in + self?.handleInteractionUpdates(updatedInteractionData) + } } - private func handleUpdates(_ updatedViewData: ConversationViewModel.ViewData, initialLoad: Bool = false) { + private func stopObservingChanges() { + // Stop observing database changes + dataChangeObservable?.cancel() + self.viewModel.onInteractionChange = nil + } + + private func handleThreadUpdates(_ updatedThreadData: ConversationCell.ViewModel, initialLoad: Bool = false) { // Ensure the first load or a load when returning from a child screen runs without animations (if // we don't do this the cells will animate in from a frame of CGRect.zero or have a buggy transition) guard hasLoadedInitialData && hasReloadedDataAfterDisappearance else { hasLoadedInitialData = true hasReloadedDataAfterDisappearance = true - UIView.performWithoutAnimation { handleUpdates(updatedViewData, initialLoad: true) } + UIView.performWithoutAnimation { handleThreadUpdates(updatedThreadData, initialLoad: true) } return } // Update general conversation UI if initialLoad || - viewModel.viewData.threadName != updatedViewData.threadName || - viewModel.viewData.thread.mutedUntilTimestamp != updatedViewData.thread.mutedUntilTimestamp || - viewModel.viewData.thread.onlyNotifyForMentions != updatedViewData.thread.onlyNotifyForMentions || - viewModel.viewData.userCount != updatedViewData.userCount + viewModel.threadData.displayName != updatedThreadData.displayName || + viewModel.threadData.threadMutedUntilTimestamp != updatedThreadData.threadMutedUntilTimestamp || + viewModel.threadData.threadOnlyNotifyForMentions != updatedThreadData.threadOnlyNotifyForMentions || + viewModel.threadData.userCount != updatedThreadData.userCount { titleView.update( - with: updatedViewData.threadName, - mutedUntilTimestamp: updatedViewData.thread.mutedUntilTimestamp, - onlyNotifyForMentions: updatedViewData.thread.onlyNotifyForMentions, - userCount: updatedViewData.userCount + with: updatedThreadData.displayName, + mutedUntilTimestamp: updatedThreadData.threadMutedUntilTimestamp, + onlyNotifyForMentions: (updatedThreadData.threadOnlyNotifyForMentions == true), + userCount: updatedThreadData.userCount ) } if initialLoad || - viewModel.viewData.requiresApproval != updatedViewData.requiresApproval || - viewModel.viewData.threadAvatarProfiles != updatedViewData.threadAvatarProfiles + viewModel.threadData.threadRequiresApproval != updatedThreadData.threadRequiresApproval || + viewModel.threadData.profile != updatedThreadData.profile { - updateNavBarButtons(viewData: updatedViewData) + updateNavBarButtons(threadData: updatedThreadData) } - if viewModel.viewData.isClosedGroupMember != updatedViewData.isClosedGroupMember { + if viewModel.threadData.currentUserIsClosedGroupMember != updatedThreadData.currentUserIsClosedGroupMember { reloadInputViews() } - if initialLoad || viewModel.viewData.enabledMessageTypes != updatedViewData.enabledMessageTypes { + if initialLoad || viewModel.threadData.enabledMessageTypes != updatedThreadData.enabledMessageTypes { snInputView.setEnabledMessageTypes( - updatedViewData.enabledMessageTypes, + updatedThreadData.enabledMessageTypes, message: nil ) } - if initialLoad || viewModel.viewData.threadIsBlocked != updatedViewData.threadIsBlocked { - addOrRemoveBlockedBanner(threadIsBlocked: updatedViewData.threadIsBlocked) + if initialLoad || viewModel.threadData.threadIsBlocked != updatedThreadData.threadIsBlocked { + addOrRemoveBlockedBanner(threadIsBlocked: (updatedThreadData.threadIsBlocked == true)) } - if initialLoad || viewModel.viewData.unreadCount != updatedViewData.unreadCount { - updateUnreadCountView(unreadCount: updatedViewData.unreadCount) + if initialLoad || viewModel.threadData.threadUnreadCount != updatedThreadData.threadUnreadCount { + updateUnreadCountView(unreadCount: updatedThreadData.threadUnreadCount) + } + } + + private func handleInteractionUpdates(_ updatedViewData: [MessageCell.ViewModel], initialLoad: Bool = false) { + // Ensure the first load or a load when returning from a child screen runs without animations (if + // we don't do this the cells will animate in from a frame of CGRect.zero or have a buggy transition) + guard hasLoadedInitialData && hasReloadedDataAfterDisappearance else { + hasLoadedInitialData = true + hasReloadedDataAfterDisappearance = true + UIView.performWithoutAnimation { handleInteractionUpdates(updatedViewData, initialLoad: true) } + return } // Reload the table content (animate changes after the first load) - let changeset = StagedChangeset(source: viewModel.viewData.items, target: updatedViewData.items) + let changeset = StagedChangeset(source: viewModel.interactionData, target: updatedViewData) tableView.reload( - using: StagedChangeset(source: viewModel.viewData.items, target: updatedViewData.items), + using: StagedChangeset(source: viewModel.interactionData, target: updatedViewData), deleteSectionsAnimation: .bottom, insertSectionsAnimation: .bottom, reloadSectionsAnimation: .none, deleteRowsAnimation: .bottom, insertRowsAnimation: .bottom, reloadRowsAnimation: .none, - interrupt: { - return $0.changeCount > 100 - } // Prevent too many changes from causing performance issues - ) { [weak self] items in - self?.viewModel.updateData(updatedViewData.with(items: items)) + interrupt: { $0.changeCount > ConversationViewModel.pageSize } + ) { [weak self] updatedData in + self?.viewModel.updateInteractionData(updatedData) } // Scroll to the bottom if we just inserted a message and are close enough @@ -601,7 +619,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers viewModel.sentMessageBeforeUpdate = false } - func updateNavBarButtons(viewData: ConversationViewModel.ViewData) { + func updateNavBarButtons(threadData: ConversationCell.ViewModel) { navigationItem.hidesBackButton = isShowingSearchUI if isShowingSearchUI { @@ -609,7 +627,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers navigationItem.rightBarButtonItems = [] } else { - guard !viewData.requiresApproval else { + guard threadData.threadRequiresApproval == false else { // Note: Adding an empty button because without it the title alignment is // busted (Note: The size was taken from the layout inspector for the back // button in Xcode @@ -626,14 +644,14 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers return } - switch viewData.thread.variant { + switch threadData.threadVariant { case .contact: let profilePictureView = ProfilePictureView() profilePictureView.size = Values.verySmallProfilePictureSize profilePictureView.update( - publicKey: viewData.thread.id, // Contact thread uses the contactId - profile: viewData.threadAvatarProfiles.first, - threadVariant: viewData.thread.variant + publicKey: threadData.threadId, // Contact thread uses the contactId + profile: threadData.profile, + threadVariant: threadData.threadVariant ) profilePictureView.set(.width, to: (44 - 16)) // Width of the standard back button profilePictureView.set(.height, to: Values.verySmallProfilePictureSize) @@ -882,26 +900,26 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers // MARK: - UITableViewDataSource func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return viewModel.viewData.items.count + return viewModel.interactionData.count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let item: ConversationViewModel.Item = viewModel.viewData.items[indexPath.row] - let cell: MessageCell = tableView.dequeue(type: MessageCell.cellType(for: item), for: indexPath) + let cellViewModel: MessageCell.ViewModel = viewModel.interactionData[indexPath.row] + let cell: MessageCell = tableView.dequeue(type: MessageCell.cellType(for: cellViewModel), for: indexPath) cell.update( - with: item, + with: cellViewModel, mediaCache: mediaCache, - playbackInfo: viewModel.playbackInfo(for: item) { updatedInfo, error in + playbackInfo: viewModel.playbackInfo(for: cellViewModel) { updatedInfo, error in DispatchQueue.main.async { guard error == nil else { OWSAlerts.showErrorAlert(message: "INVALID_AUDIO_FILE_ALERT_ERROR_MESSAGE".localized()) return } - cell.dynamicUpdate(with: item, playbackInfo: updatedInfo) + cell.dynamicUpdate(with: cellViewModel, playbackInfo: updatedInfo) } }, - lastSearchText: viewModel.viewData.lastSearchedText + lastSearchText: viewModel.lastSearchedText ) cell.delegate = self @@ -919,11 +937,11 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers } func scrollToBottom(isAnimated: Bool) { - guard !isUserScrolling && !viewModel.viewData.items.isEmpty else { return } + guard !isUserScrolling && !viewModel.interactionData.isEmpty else { return } tableView.scrollToRow( at: IndexPath( - row: viewModel.viewData.items.count - 1, + row: viewModel.interactionData.count - 1, section: 0), at: .bottom, animated: isAnimated @@ -944,7 +962,8 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers autoLoadMoreIfNeeded() } - func updateUnreadCountView(unreadCount: Int) { + func updateUnreadCountView(unreadCount: UInt?) { + let unreadCount: Int = Int(unreadCount ?? 0) let fontSize: CGFloat = (unreadCount < 10000 ? Values.verySmallFontSize : 8) unreadCountLabel.text = (unreadCount < 10000 ? "\(unreadCount)" : "9999+") unreadCountLabel.font = .boldSystemFont(ofSize: fontSize) @@ -996,7 +1015,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers navigationItem.titleView = searchBar // Nav bar buttons - updateNavBarButtons(viewData: viewModel.viewData) + updateNavBarButtons(threadData: self.viewModel.threadData) // Hack so that the ResultsBar stays on the screen when dismissing the search field // keyboard. @@ -1032,7 +1051,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers func hideSearchUI() { isShowingSearchUI = false navigationItem.titleView = titleView - updateNavBarButtons(viewData: viewModel.viewData) + updateNavBarButtons(threadData: self.viewModel.threadData) let navBar = navigationController!.navigationBar as! OWSNavigationBar navBar.stubbedNextResponder = nil diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index 0a8ab0dac..7ef7ef60b 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -1,3 +1,513 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import GRDB +import DifferenceKit +import SessionMessagingKit +import SessionUtilitiesKit + +public class ConversationViewModel: OWSAudioPlayerDelegate { + public enum Action { + case none + case compose + case audioCall + case videoCall + } + + public static let pageSize: Int = 50 + + // MARK: - Initialization + + init?(threadId: String, focusedInteractionId: Int64?) { + let maybeThreadData: ConversationCell.ViewModel? = GRDBStorage.shared.read { db in + let userPublicKey: String = getUserHexEncodedPublicKey(db) + + return try ConversationCell.ViewModel + .conversationQuery( + threadId: threadId, + userPublicKey: userPublicKey + ) + .fetchOne(db) + } + + guard let threadData: ConversationCell.ViewModel = maybeThreadData else { return nil } + + self.threadId = threadId + self.threadData = threadData + self.focusedInteractionId = focusedInteractionId // TODO: This + self.pagedDataObserver = nil + var hasSavedIntialUpdate: Bool = false + self.pagedDataObserver = PagedDatabaseObserver( + pagedTable: Interaction.self, + pageSize: ConversationViewModel.pageSize, + idColumn: .id, + initialFocusedId: nil, + observedChanges: [ + PagedData.ObservedChanges( + table: Interaction.self, + columns: Interaction.Columns + .allCases + .filter { $0 != .wasRead } + ) + ], + filterSQL: MessageCell.ViewModel.filterSQL(threadId: threadId), + orderSQL: MessageCell.ViewModel.orderSQL, + dataQuery: MessageCell.ViewModel.baseQuery( + orderSQL: MessageCell.ViewModel.orderSQL, + baseFilterSQL: MessageCell.ViewModel.filterSQL(threadId: threadId) + ), + associatedRecords: [ + AssociatedRecord( + trackedAgainst: Attachment.self, + observedChanges: [ + PagedData.ObservedChanges( + table: Attachment.self, + columns: [.state] + ) + ], + dataQuery: MessageCell.AttachmentInteractionInfo.baseQuery, + joinToPagedType: MessageCell.AttachmentInteractionInfo.joinToViewModelQuerySQL, + associateData: MessageCell.AttachmentInteractionInfo.createAssociateDataClosure() + ) + ], + onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in + guard let updatedInteractionData: [MessageCell.ViewModel] = self?.process(data: updatedData, for: updatedPageInfo) else { + return + } + + // If we haven't stored the data for the initial fetch then do so now (no need + // to call 'onInteractionsChange' in this case as it will always be null) + guard hasSavedIntialUpdate else { + self?.updateInteractionData(updatedInteractionData) + hasSavedIntialUpdate = true + return + } + + self?.onInteractionChange?(updatedInteractionData) + } + ) + } + + // MARK: - Variables + + private let threadId: String + public var sentMessageBeforeUpdate: Bool = false + public var lastSearchedText: String? + public let focusedInteractionId: Int64? // Note: This is used for global search + + public lazy var blockedBannerMessage: String = { + switch self.threadData.threadVariant { + case .contact: + let name: String = Profile.displayName( + id: self.threadData.threadId, + threadVariant: self.threadData.threadVariant + ) + + return "\(name) is blocked. Unblock them?" + + default: return "Thread is blocked. Unblock it?" + } + }() + + // MARK: - Thread Data + + /// This value is the current state of the view + public private(set) var threadData: ConversationCell.ViewModel + + public lazy var observableThreadData = ValueObservation + .trackingConstantRegion { [threadId = self.threadId] db -> ConversationCell.ViewModel? in + let userPublicKey: String = getUserHexEncodedPublicKey(db) + + return try ConversationCell.ViewModel + .conversationQuery(threadId: threadId, userPublicKey: userPublicKey) + .fetchOne(db) + } + .removeDuplicates() + + public func updateThreadData(_ updatedData: ConversationCell.ViewModel) { + self.threadData = updatedData + } + + // MARK: - Interaction Data + + public private(set) var interactionData: [MessageCell.ViewModel] = [] + public private(set) var pagedDataObserver: PagedDatabaseObserver? + public var onInteractionChange: (([MessageCell.ViewModel]) -> ())? + + private func process(data: [MessageCell.ViewModel], for pageInfo: PagedData.PageInfo) -> [MessageCell.ViewModel] { + let sortedData: [MessageCell.ViewModel] = data + .sorted { lhs, rhs -> Bool in lhs.timestampMs < rhs.timestampMs } + + return sortedData + .enumerated() + .map { index, cellViewModel -> MessageCell.ViewModel in + cellViewModel.withClusteringChanges( + prevModel: (index > 0 ? sortedData[index - 1] : nil), + nextModel: (index < (sortedData.count - 2) ? sortedData[index + 1] : nil), + isLast: ( + index == (sortedData.count - 1) && + pageInfo.currentCount == pageInfo.totalCount + ) + ) + } + } + + public func updateInteractionData(_ updatedData: [MessageCell.ViewModel]) { + self.interactionData = updatedData + } + + // MARK: - Mentions + + public struct MentionInfo: FetchableRecord, Decodable { + fileprivate static let threadVariantKey = CodingKeys.threadVariant.stringValue + fileprivate static let openGroupRoomKey = CodingKeys.openGroupRoom.stringValue + fileprivate static let openGroupServerKey = CodingKeys.openGroupServer.stringValue + + let profile: Profile + let threadVariant: SessionThread.Variant + let openGroupRoom: String? + let openGroupServer: String? + } + + public func mentions(for query: String = "") -> [MentionInfo] { + let threadData: ConversationCell.ViewModel = self.threadData + + let results: [MentionInfo] = GRDBStorage.shared + .read { db -> [MentionInfo] in + let userPublicKey: String = getUserHexEncodedPublicKey(db) + + switch threadData.threadVariant { + case .contact: + guard userPublicKey != threadData.threadId else { return [] } + + return [Profile.fetchOrCreate(db, id: threadData.threadId)] + .map { profile in + MentionInfo( + profile: profile, + threadVariant: threadData.threadVariant, + openGroupRoom: nil, + openGroupServer: nil + ) + } + .filter { + query.count < 2 || + $0.profile.displayName(for: $0.threadVariant).contains(query) + } + + case .closedGroup: + let profile: TypedTableAlias = TypedTableAlias() + + return try GroupMember + .select( + profile.allColumns(), + SQL("\(threadData.threadVariant)").forKey(MentionInfo.threadVariantKey) + ) + .filter(GroupMember.Columns.groupId == threadData.threadId) + .filter(GroupMember.Columns.profileId != userPublicKey) + .filter(GroupMember.Columns.role == GroupMember.Role.standard) + .joining( + required: GroupMember.profile + .aliased(profile) + // Note: LIKE is case-insensitive in SQLite + .filter( + query.count < 2 || ( + profile[.nickname] != nil && + profile[.nickname].like("%\(query)%") + ) || ( + profile[.nickname] == nil && + profile[.name].like("%\(query)%") + ) + ) + ) + .asRequest(of: MentionInfo.self) + .fetchAll(db) + + case .openGroup: + let profile: TypedTableAlias = TypedTableAlias() + + return try Interaction + .select( + profile.allColumns(), + SQL("\(threadData.threadVariant)").forKey(MentionInfo.threadVariantKey), + SQL("\(threadData.openGroupRoom)").forKey(MentionInfo.openGroupRoomKey), + SQL("\(threadData.openGroupServer)").forKey(MentionInfo.openGroupServerKey) + ) + .distinct() + .group(Interaction.Columns.authorId) + .filter(Interaction.Columns.threadId == threadData.threadId) + .filter(Interaction.Columns.authorId != userPublicKey) + .joining( + required: Interaction.profile + .aliased(profile) + // Note: LIKE is case-insensitive in SQLite + .filter( + query.count < 2 || ( + profile[.nickname] != nil && + profile[.nickname].like("%\(query)%") + ) || ( + profile[.nickname] == nil && + profile[.name].like("%\(query)%") + ) + ) + ) + .order(Interaction.Columns.timestampMs.desc) + .limit(20) + .asRequest(of: MentionInfo.self) + .fetchAll(db) + } + } + .defaulting(to: []) + + guard query.count >= 2 else { + return results.sorted { lhs, rhs -> Bool in + lhs.profile.displayName(for: lhs.threadVariant) < rhs.profile.displayName(for: rhs.threadVariant) + } + } + + return results + .sorted { lhs, rhs -> Bool in + let maybeLhsRange = lhs.profile.displayName(for: lhs.threadVariant).lowercased().range(of: query.lowercased()) + let maybeRhsRange = rhs.profile.displayName(for: rhs.threadVariant).lowercased().range(of: query.lowercased()) + + guard let lhsRange: Range = maybeLhsRange, let rhsRange: Range = maybeRhsRange else { + return true + } + + return (lhsRange.lowerBound < rhsRange.lowerBound) + } + } + + // MARK: - Functions + + public func updateDraft(to draft: String) { + GRDBStorage.shared.write { db in + try SessionThread + .filter(id: self.threadId) + .updateAll(db, SessionThread.Columns.messageDraft.set(to: draft)) + } + } + + public func markAllAsRead() { + guard let lastInteractionId: Int64 = self.interactionData.last?.id else { return } + + GRDBStorage.shared.write { db in + try Interaction.markAsRead( + db, + interactionId: lastInteractionId, + threadId: self.threadData.threadId, + includingOlder: true, + trySendReadReceipt: (self.threadData.threadIsMessageRequest == false) + ) + } + } + + // MARK: - Audio Playback + + public struct PlaybackInfo { + let state: AudioPlaybackState + let progress: TimeInterval + let playbackRate: Double + let oldPlaybackRate: Double + let updateCallback: (PlaybackInfo?, Error?) -> () + + public func with( + state: AudioPlaybackState? = nil, + progress: TimeInterval? = nil, + playbackRate: Double? = nil, + updateCallback: ((PlaybackInfo?, Error?) -> ())? = nil + ) -> PlaybackInfo { + return PlaybackInfo( + state: (state ?? self.state), + progress: (progress ?? self.progress), + playbackRate: (playbackRate ?? self.playbackRate), + oldPlaybackRate: self.playbackRate, + updateCallback: (updateCallback ?? self.updateCallback) + ) + } + } + + private var audioPlayer: Atomic = Atomic(nil) + private var currentPlayingInteraction: Atomic = Atomic(nil) + private var playbackInfo: Atomic<[Int64: PlaybackInfo]> = Atomic([:]) + + public func playbackInfo(for viewModel: MessageCell.ViewModel, updateCallback: ((PlaybackInfo?, Error?) -> ())? = nil) -> PlaybackInfo? { + // Use the existing info if it already exists (update it's callback if provided as that means + // the cell was reloaded) + if let currentPlaybackInfo: PlaybackInfo = playbackInfo.wrappedValue[viewModel.id] { + let updatedPlaybackInfo: PlaybackInfo = currentPlaybackInfo + .with(updateCallback: updateCallback) + + playbackInfo.mutate { $0[viewModel.id] = updatedPlaybackInfo } + + return updatedPlaybackInfo + } + + // Validate the item is a valid audio item + guard + let updateCallback: ((PlaybackInfo?, Error?) -> ()) = updateCallback, + let attachment: Attachment = viewModel.attachments?.first, + attachment.isAudio, + attachment.isValid, + let originalFilePath: String = attachment.originalFilePath, + FileManager.default.fileExists(atPath: originalFilePath) + else { return nil } + + // Create the info with the update callback + let newPlaybackInfo: PlaybackInfo = PlaybackInfo( + state: .stopped, + progress: 0, + playbackRate: 1, + oldPlaybackRate: 1, + updateCallback: updateCallback + ) + + // Cache the info + playbackInfo.mutate { $0[viewModel.id] = newPlaybackInfo } + + return newPlaybackInfo + } + + public func playOrPauseAudio(for viewModel: MessageCell.ViewModel) { + guard + let attachment: Attachment = viewModel.attachments?.first, + let originalFilePath: String = attachment.originalFilePath, + FileManager.default.fileExists(atPath: originalFilePath) + else { return } + + // If the user interacted with the currently playing item + guard currentPlayingInteraction.wrappedValue != viewModel.id else { + let currentPlaybackInfo: PlaybackInfo? = playbackInfo.wrappedValue[viewModel.id] + let updatedPlaybackInfo: PlaybackInfo? = currentPlaybackInfo? + .with( + state: (currentPlaybackInfo?.state != .playing ? .playing : .paused), + playbackRate: 1 + ) + + audioPlayer.wrappedValue?.playbackRate = 1 + + switch currentPlaybackInfo?.state { + case .playing: audioPlayer.wrappedValue?.pause() + default: audioPlayer.wrappedValue?.play() + } + + // Update the state and then update the UI with the updated state + playbackInfo.mutate { $0[viewModel.id] = updatedPlaybackInfo } + updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, nil) + return + } + + // First stop any existing audio + audioPlayer.wrappedValue?.stop() + + // Then setup the state for the new audio + currentPlayingInteraction.mutate { $0 = viewModel.id } + + audioPlayer.mutate { [weak self] player in + let audioPlayer: OWSAudioPlayer = OWSAudioPlayer( + mediaUrl: URL(fileURLWithPath: originalFilePath), + audioBehavior: .audioMessagePlayback, + delegate: self + ) + audioPlayer.play() + audioPlayer.setCurrentTime(playbackInfo.wrappedValue[viewModel.id]?.progress ?? 0) + player = audioPlayer + } + } + + public func speedUpAudio(for viewModel: MessageCell.ViewModel) { + // If we aren't playing the specified item then just start playing it + guard viewModel.id == currentPlayingInteraction.wrappedValue else { + playOrPauseAudio(for: viewModel) + return + } + + let updatedPlaybackInfo: PlaybackInfo? = playbackInfo.wrappedValue[viewModel.id]? + .with(playbackRate: 1.5) + + // Speed up the audio player + audioPlayer.wrappedValue?.playbackRate = 1.5 + + playbackInfo.mutate { $0[viewModel.id] = updatedPlaybackInfo } + updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, nil) + } + + public func stopAudio() { + audioPlayer.wrappedValue?.stop() + + currentPlayingInteraction.mutate { $0 = nil } + audioPlayer.mutate { $0 = nil } + } + + // MARK: - OWSAudioPlayerDelegate + + public func audioPlaybackState() -> AudioPlaybackState { + guard let interactionId: Int64 = currentPlayingInteraction.wrappedValue else { return .stopped } + + return (playbackInfo.wrappedValue[interactionId]?.state ?? .stopped) + } + + public func setAudioPlaybackState(_ state: AudioPlaybackState) { + guard let interactionId: Int64 = currentPlayingInteraction.wrappedValue else { return } + + let updatedPlaybackInfo: PlaybackInfo? = playbackInfo.wrappedValue[interactionId]? + .with(state: state) + + playbackInfo.mutate { $0[interactionId] = updatedPlaybackInfo } + updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, nil) + } + + public func setAudioProgress(_ progress: CGFloat, duration: CGFloat) { + guard let interactionId: Int64 = currentPlayingInteraction.wrappedValue else { return } + + let updatedPlaybackInfo: PlaybackInfo? = playbackInfo.wrappedValue[interactionId]? + .with(progress: TimeInterval(progress)) + + playbackInfo.mutate { $0[interactionId] = updatedPlaybackInfo } + updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, nil) + } + + public func audioPlayerDidFinishPlaying(_ player: OWSAudioPlayer, successfully: Bool) { + guard let interactionId: Int64 = currentPlayingInteraction.wrappedValue else { return } + guard successfully else { return } + + let updatedPlaybackInfo: PlaybackInfo? = playbackInfo.wrappedValue[interactionId]? + .with( + state: .stopped, + progress: 0, + playbackRate: 1 + ) + + // Safe the changes and send one final update to the UI + playbackInfo.mutate { $0[interactionId] = updatedPlaybackInfo } + updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, nil) + + // Clear out the currently playing record + currentPlayingInteraction.mutate { $0 = nil } + audioPlayer.mutate { $0 = nil } + + // If the next interaction is another voice message then autoplay it + guard + let currentIndex: Int = self.interactionData.firstIndex(where: { $0.id == interactionId }), + currentIndex < (self.interactionData.count - 1), + self.interactionData[currentIndex + 1].cellType == .audio + else { return } + + let nextItem: MessageCell.ViewModel = self.interactionData[currentIndex + 1] + playOrPauseAudio(for: nextItem) + } + + public func showInvalidAudioFileAlert() { + guard let interactionId: Int64 = currentPlayingInteraction.wrappedValue else { return } + + let updatedPlaybackInfo: PlaybackInfo? = playbackInfo.wrappedValue[interactionId]? + .with( + state: .stopped, + progress: 0, + playbackRate: 1 + ) + + currentPlayingInteraction.mutate { $0 = nil } + playbackInfo.mutate { $0[interactionId] = updatedPlaybackInfo } + updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, AttachmentError.invalidData) + } +} diff --git a/Session/Conversations/Input View/InputView.swift b/Session/Conversations/Input View/InputView.swift index 3c28bfdcf..9504bdb63 100644 --- a/Session/Conversations/Input View/InputView.swift +++ b/Session/Conversations/Input View/InputView.swift @@ -5,12 +5,6 @@ import SessionUIKit import SessionMessagingKit final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, MentionSelectionViewDelegate { - enum MessageTypes: Equatable { - case all - case textOnly - case none - } - // MARK: - Variables private static let linkPreviewViewInset: CGFloat = 6 @@ -37,7 +31,7 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M set { inputTextView.text = newValue } } - var enabledMessageTypes: MessageTypes = .all { + var enabledMessageTypes: MessageInputTypes = .all { didSet { setEnabledMessageTypes(enabledMessageTypes, message: nil) } @@ -308,7 +302,7 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M .retainUntilComplete() } - func setEnabledMessageTypes(_ messageTypes: MessageTypes, message: String?) { + func setEnabledMessageTypes(_ messageTypes: MessageInputTypes, message: String?) { guard enabledMessageTypes != messageTypes else { return } enabledMessageTypes = messageTypes diff --git a/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift b/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift index 204cef5f3..076d49d94 100644 --- a/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift +++ b/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift @@ -128,7 +128,7 @@ final class LinkPreviewView: UIView { with state: LinkPreviewState, isOutgoing: Bool, delegate: (UITextViewDelegate & BodyTextViewDelegate)? = nil, - item: ConversationViewModel.Item? = nil, + cellViewModel: MessageCell.ViewModel? = nil, bodyLabelTextColor: UIColor? = nil, lastSearchText: String? = nil ) { @@ -184,9 +184,9 @@ final class LinkPreviewView: UIView { // Body text view bodyTextViewContainer.subviews.forEach { $0.removeFromSuperview() } - if let item: ConversationViewModel.Item = item { + if let cellViewModel: MessageCell.ViewModel = cellViewModel { let bodyTextView = VisibleMessageCell.getBodyTextView( - for: item, + for: cellViewModel, with: maxWidth, textColor: (bodyLabelTextColor ?? sentLinkPreviewTextColor), searchText: lastSearchText, diff --git a/Session/Conversations/Message Cells/Content Views/MediaPlaceholderView.swift b/Session/Conversations/Message Cells/Content Views/MediaPlaceholderView.swift index 4f65a24d5..f25b33bf6 100644 --- a/Session/Conversations/Message Cells/Content Views/MediaPlaceholderView.swift +++ b/Session/Conversations/Message Cells/Content Views/MediaPlaceholderView.swift @@ -9,10 +9,10 @@ final class MediaPlaceholderView: UIView { // MARK: - Lifecycle - init(item: ConversationViewModel.Item, textColor: UIColor) { + init(cellViewModel: MessageCell.ViewModel, textColor: UIColor) { super.init(frame: CGRect.zero) - setUpViewHierarchy(item: item, textColor: textColor) + setUpViewHierarchy(cellViewModel: cellViewModel, textColor: textColor) } override init(frame: CGRect) { @@ -24,13 +24,13 @@ final class MediaPlaceholderView: UIView { } private func setUpViewHierarchy( - item: ConversationViewModel.Item, + cellViewModel: MessageCell.ViewModel, textColor: UIColor ) { let (iconName, attachmentDescription): (String, String) = { guard - item.interactionVariant == .standardIncoming, - let attachment: Attachment = item.attachments?.first + cellViewModel.variant == .standardIncoming, + let attachment: Attachment = cellViewModel.attachments?.first else { return ("actionsheet_document_black", "file") // Should never occur } diff --git a/Session/Conversations/Message Cells/InfoMessageCell.swift b/Session/Conversations/Message Cells/InfoMessageCell.swift index 26476d18f..a8dfb6ae0 100644 --- a/Session/Conversations/Message Cells/InfoMessageCell.swift +++ b/Session/Conversations/Message Cells/InfoMessageCell.swift @@ -52,20 +52,15 @@ final class InfoMessageCell: MessageCell { // MARK: - Updating - override func update(with item: ConversationViewModel.Item, mediaCache: NSCache, playbackInfo: ConversationViewModel.PlaybackInfo?, lastSearchText: String?) { - switch item.interactionVariant { - case .infoClosedGroupCreated, .infoClosedGroupUpdated, .infoClosedGroupCurrentUserLeft, - .infoDisappearingMessagesUpdate, .infoScreenshotNotification, .infoMediaSavedNotification, - .infoMessageRequestAccepted: - break - - default: return // Ignore non-info variants - } + override func update(with cellViewModel: MessageCell.ViewModel, mediaCache: NSCache, playbackInfo: ConversationViewModel.PlaybackInfo?, lastSearchText: String?) { + guard cellViewModel.variant.isInfoMessage else { return } + + self.viewModel = cellViewModel let icon: UIImage? = { - switch item.interactionVariant { + switch cellViewModel.variant { case .infoDisappearingMessagesUpdate: - return (item.threadHasDisappearingMessagesEnabled ? + return (cellViewModel.threadHasDisappearingMessagesEnabled ? UIImage(named: "ic_timer") : UIImage(named: "ic_timer_disabled") ) @@ -83,9 +78,9 @@ final class InfoMessageCell: MessageCell { iconImageViewWidthConstraint.constant = (icon != nil) ? InfoMessageCell.iconSize : 0 iconImageViewHeightConstraint.constant = (icon != nil) ? InfoMessageCell.iconSize : 0 - self.label.text = item.body + self.label.text = cellViewModel.body } - override func dynamicUpdate(with item: ConversationViewModel.Item, playbackInfo: ConversationViewModel.PlaybackInfo?) { + override func dynamicUpdate(with cellViewModel: MessageCell.ViewModel, playbackInfo: ConversationViewModel.PlaybackInfo?) { } } diff --git a/Session/Conversations/Message Cells/MessageCell.swift b/Session/Conversations/Message Cells/MessageCell.swift index f7675242d..809ae0f14 100644 --- a/Session/Conversations/Message Cells/MessageCell.swift +++ b/Session/Conversations/Message Cells/MessageCell.swift @@ -9,9 +9,9 @@ public enum SwipeState { case cancelled } -class MessageCell: UITableViewCell { +public class MessageCell: UITableViewCell { weak var delegate: MessageCellDelegate? - var item: ConversationViewModel.Item? + var viewModel: MessageCell.ViewModel? // MARK: - Lifecycle @@ -43,22 +43,22 @@ class MessageCell: UITableViewCell { // MARK: - Updating - func update(with item: ConversationViewModel.Item, mediaCache: NSCache, playbackInfo: ConversationViewModel.PlaybackInfo?, lastSearchText: String?) { + func update(with cellViewModel: MessageCell.ViewModel, mediaCache: NSCache, playbackInfo: ConversationViewModel.PlaybackInfo?, lastSearchText: String?) { preconditionFailure("Must be overridden by subclasses.") } /// This is a cut-down version of the 'update' function which doesn't re-create the UI (it should be used for dynamically-updating content /// like playing inline audio/video) - func dynamicUpdate(with item: ConversationViewModel.Item, playbackInfo: ConversationViewModel.PlaybackInfo?) { + func dynamicUpdate(with cellViewModel: MessageCell.ViewModel, playbackInfo: ConversationViewModel.PlaybackInfo?) { preconditionFailure("Must be overridden by subclasses.") } // MARK: - Convenience - static func cellType(for item: ConversationViewModel.Item) -> MessageCell.Type { - guard item.cellType != .typingIndicator else { return TypingIndicatorCell.self } + static func cellType(for viewModel: MessageCell.ViewModel) -> MessageCell.Type { + guard viewModel.cellType != .typingIndicator else { return TypingIndicatorCell.self } - switch item.interactionVariant { + switch viewModel.variant { case .standardOutgoing, .standardIncoming, .standardIncomingDeleted: return VisibleMessageCell.self @@ -70,16 +70,14 @@ class MessageCell: UITableViewCell { } } -protocol MessageCellDelegate : AnyObject { - var lastSearchedText: String? { get } - - func getMediaCache() -> NSCache - func handleViewItemLongPressed(_ viewItem: ConversationViewItem) - func handleViewItemTapped(_ viewItem: ConversationViewItem, gestureRecognizer: UITapGestureRecognizer) - func handleViewItemDoubleTapped(_ viewItem: ConversationViewItem) - func handleViewItemSwiped(_ viewItem: ConversationViewItem, state: SwipeState) - func showFullText(_ viewItem: ConversationViewItem) - func openURL(_ url: URL) - func handleReplyButtonTapped(for viewItem: ConversationViewItem) - func showUserDetails(for sessionID: String) +// MARK: - MessageCellDelegate + +protocol MessageCellDelegate: AnyObject { + func handleItemLongPressed(_ cellViewModel: MessageCell.ViewModel) + func handleItemTapped(_ cellViewModel: MessageCell.ViewModel, gestureRecognizer: UITapGestureRecognizer) + func handleItemDoubleTapped(_ cellViewModel: MessageCell.ViewModel) + func handleItemSwiped(_ cellViewModel: MessageCell.ViewModel, state: SwipeState) + func openUrl(_ urlString: String) + func handleReplyButtonTapped(for cellViewModel: MessageCell.ViewModel) + func showUserDetails(for profile: Profile) } diff --git a/Session/Conversations/Message Cells/Models/MessageCellViewModel.swift b/Session/Conversations/Message Cells/Models/MessageCellViewModel.swift new file mode 100644 index 000000000..adbcd40c0 --- /dev/null +++ b/Session/Conversations/Message Cells/Models/MessageCellViewModel.swift @@ -0,0 +1,593 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import DifferenceKit +import SessionUtilitiesKit +import SessionMessagingKit + +fileprivate typealias ViewModel = MessageCell.ViewModel +fileprivate typealias AttachmentInteractionInfo = MessageCell.AttachmentInteractionInfo + +extension MessageCell { + public struct ViewModel: FetchableRecordWithRowId, Decodable, Equatable, Hashable, Identifiable, Differentiable { + public static let threadVariantKey: SQL = SQL(stringLiteral: CodingKeys.threadVariant.stringValue) + public static let threadIsTrustedKey: SQL = SQL(stringLiteral: CodingKeys.threadIsTrusted.stringValue) + public static let threadHasDisappearingMessagesEnabledKey: SQL = SQL(stringLiteral: CodingKeys.threadHasDisappearingMessagesEnabled.stringValue) + public static let rowIdKey: SQL = SQL(stringLiteral: CodingKeys.rowId.stringValue) + public static let authorNameInternalKey: SQL = SQL(stringLiteral: CodingKeys.authorNameInternal.stringValue) + public static let stateKey: SQL = SQL(stringLiteral: CodingKeys.state.stringValue) + public static let hasAtLeastOneReadReceiptKey: SQL = SQL(stringLiteral: CodingKeys.hasAtLeastOneReadReceipt.stringValue) + public static let mostRecentFailureTextKey: SQL = SQL(stringLiteral: CodingKeys.mostRecentFailureText.stringValue) + public static let isTypingIndicatorKey: SQL = SQL(stringLiteral: CodingKeys.isTypingIndicator.stringValue) + public static let isSenderOpenGroupModeratorKey: SQL = SQL(stringLiteral: CodingKeys.isSenderOpenGroupModerator.stringValue) + public static let profileKey: SQL = SQL(stringLiteral: CodingKeys.profile.stringValue) + public static let quoteKey: SQL = SQL(stringLiteral: CodingKeys.quote.stringValue) + public static let quoteAttachmentKey: SQL = SQL(stringLiteral: CodingKeys.quoteAttachment.stringValue) + public static let linkPreviewKey: SQL = SQL(stringLiteral: CodingKeys.linkPreview.stringValue) + public static let linkPreviewAttachmentKey: SQL = SQL(stringLiteral: CodingKeys.linkPreviewAttachment.stringValue) + public static let cellTypeKey: SQL = SQL(stringLiteral: CodingKeys.cellType.stringValue) + public static let authorNameKey: SQL = SQL(stringLiteral: CodingKeys.authorName.stringValue) + public static let shouldShowProfileKey: SQL = SQL(stringLiteral: CodingKeys.shouldShowProfile.stringValue) + public static let positionInClusterKey: SQL = SQL(stringLiteral: CodingKeys.positionInCluster.stringValue) + public static let isOnlyMessageInClusterKey: SQL = SQL(stringLiteral: CodingKeys.isOnlyMessageInCluster.stringValue) + public static let isLastKey: SQL = SQL(stringLiteral: CodingKeys.isLast.stringValue) + + public static let profileString: String = CodingKeys.profile.stringValue + public static let quoteString: String = CodingKeys.quote.stringValue + public static let quoteAttachmentString: String = CodingKeys.quoteAttachment.stringValue + public static let linkPreviewString: String = CodingKeys.linkPreview.stringValue + public static let linkPreviewAttachmentString: String = CodingKeys.linkPreviewAttachment.stringValue + + public enum Position: Int, Decodable, Equatable, Hashable, DatabaseValueConvertible { + case top + case middle + case bottom + } + + public enum CellType: Int, Decodable, Equatable, Hashable, DatabaseValueConvertible { + case textOnlyMessage + case mediaMessage + case audio + case genericAttachment + case typingIndicator + } + + public var differenceIdentifier: ViewModel { self } + + // Thread Info + + let threadVariant: SessionThread.Variant + let threadIsTrusted: Bool + let threadHasDisappearingMessagesEnabled: Bool + + // Interaction Info + + public let rowId: Int64 + public let id: Int64 + let variant: Interaction.Variant + let timestampMs: Int64 + let authorId: String + private let authorNameInternal: String? + let body: String? + let expiresStartedAtMs: Double? + let expiresInSeconds: TimeInterval? + + let state: RecipientState.State + let hasAtLeastOneReadReceipt: Bool + let mostRecentFailureText: String? + let isTypingIndicator: Bool + let isSenderOpenGroupModerator: Bool + let profile: Profile? + let quote: Quote? + let quoteAttachment: Attachment? + let linkPreview: LinkPreview? + let linkPreviewAttachment: Attachment? + + // Post-Query Processing Data + + /// This value includes the associated attachments + let attachments: [Attachment]? + + /// This value defines what type of cell should appear and is generated based on the interaction variant + /// and associated attachment data + let cellType: CellType + + /// This value includes the author name information + let authorName: String + + /// This value will be used to populate the author label, if it's null then the label will be hidden + let senderName: String? + + /// A flag indicating whether the profile view should be displayed + let shouldShowProfile: Bool + + /// This value will be used to populate the date header, if it's null then the header will be hidden + let dateForUI: Date? + + /// This value indicates the variant of the previous ViewModel item, if it's null then there is no previous item + let previousVariant: Interaction.Variant? + + /// This value indicates the position of this message within a cluser of messages + let positionInCluster: Position + + /// This value indicates whether this is the only message in a cluser of messages + let isOnlyMessageInCluster: Bool + + /// This value indicates whether this is the last message in the thread + let isLast: Bool + + // MARK: - Mutation + + public func with(attachments: [Attachment]) -> ViewModel { + return ViewModel( + threadVariant: self.threadVariant, + threadIsTrusted: self.threadIsTrusted, + threadHasDisappearingMessagesEnabled: self.threadHasDisappearingMessagesEnabled, + rowId: self.rowId, + id: self.id, + variant: self.variant, + timestampMs: self.timestampMs, + authorId: self.authorId, + authorNameInternal: self.authorNameInternal, + body: self.body, + expiresStartedAtMs: self.expiresStartedAtMs, + expiresInSeconds: self.expiresInSeconds, + state: self.state, + hasAtLeastOneReadReceipt: self.hasAtLeastOneReadReceipt, + mostRecentFailureText: self.mostRecentFailureText, + isTypingIndicator: self.isTypingIndicator, + isSenderOpenGroupModerator: self.isSenderOpenGroupModerator, + profile: self.profile, + quote: self.quote, + quoteAttachment: self.quoteAttachment, + linkPreview: self.linkPreview, + linkPreviewAttachment: self.linkPreviewAttachment, + attachments: attachments, + cellType: self.cellType, + authorName: self.authorName, + senderName: self.senderName, + shouldShowProfile: self.shouldShowProfile, + dateForUI: self.dateForUI, + previousVariant: self.previousVariant, + positionInCluster: self.positionInCluster, + isOnlyMessageInCluster: self.isOnlyMessageInCluster, + isLast: self.isLast + ) + } + + public func withClusteringChanges( + prevModel: ViewModel?, + nextModel: ViewModel?, + isLast: Bool + ) -> ViewModel { + let cellType: CellType = { + guard !self.isTypingIndicator else { return .typingIndicator } + guard self.variant != .standardIncomingDeleted else { return .textOnlyMessage } + guard let attachment: Attachment = self.attachments?.first else { return .textOnlyMessage } + + // The only case which currently supports multiple attachments is a 'mediaMessage' + // (the album view) + guard self.attachments?.count == 1 else { return .mediaMessage } + + // Quote and LinkPreview overload the 'attachments' array and use it for their + // own purposes, otherwise check if the attachment is visual media + guard self.quote == nil else { return .textOnlyMessage } + guard self.linkPreview == nil else { return .textOnlyMessage } + + // Pending audio attachments won't have a duration + if + attachment.isAudio && ( + ((attachment.duration ?? 0) > 0) || + ( + attachment.state != .downloaded && + attachment.state != .uploaded + ) + ) + { + return .audio + } + + if attachment.isVisualMedia { + return .mediaMessage + } + + return .genericAttachment + }() + let authorDisplayName: String = Profile.displayName( + for: self.threadVariant, + id: self.authorId, + name: self.authorNameInternal, + nickname: nil // Folded into 'authorName' within the Query + ) + let shouldShowDateOnThisModel: Bool = { + guard !self.isTypingIndicator else { return false } + guard let prevModel: ViewModel = prevModel else { return true } + + return DateUtil.shouldShowDateBreak( + forTimestamp: UInt64(prevModel.timestampMs), + timestamp: UInt64(self.timestampMs) + ) + }() + let shouldShowDateOnNextModel: Bool = { + // Should be nothing after a typing indicator + guard !self.isTypingIndicator else { return false } + guard let nextModel: ViewModel = nextModel else { return false } + + return DateUtil.shouldShowDateBreak( + forTimestamp: UInt64(self.timestampMs), + timestamp: UInt64(nextModel.timestampMs) + ) + }() + let (positionInCluster, isOnlyMessageInCluster): (Position, Bool) = { + let isFirstInCluster: Bool = ( + prevModel == nil || + shouldShowDateOnThisModel || ( + self.variant == .standardOutgoing && + prevModel?.variant != .standardOutgoing + ) || ( + ( + self.variant == .standardIncoming || + self.variant == .standardIncomingDeleted + ) && ( + prevModel?.variant != .standardIncoming && + prevModel?.variant != .standardIncomingDeleted + ) + ) || + self.authorId != prevModel?.authorId + ) + let isLastInCluster: Bool = ( + nextModel == nil || + shouldShowDateOnNextModel || ( + self.variant == .standardOutgoing && + nextModel?.variant != .standardOutgoing + ) || ( + ( + self.variant == .standardIncoming || + self.variant == .standardIncomingDeleted + ) && ( + nextModel?.variant != .standardIncoming && + nextModel?.variant != .standardIncomingDeleted + ) + ) || + self.authorId != nextModel?.authorId + ) + + let isOnlyMessageInCluster: Bool = (isFirstInCluster && isLastInCluster) + + switch (isFirstInCluster, isLastInCluster) { + case (true, true), (false, false): return (.middle, isOnlyMessageInCluster) + case (true, false): return (.top, isOnlyMessageInCluster) + case (false, true): return (.bottom, isOnlyMessageInCluster) + } + }() + + return ViewModel( + threadVariant: self.threadVariant, + threadIsTrusted: self.threadIsTrusted, + threadHasDisappearingMessagesEnabled: self.threadHasDisappearingMessagesEnabled, + rowId: self.rowId, + id: self.id, + variant: self.variant, + timestampMs: self.timestampMs, + authorId: self.authorId, + authorNameInternal: self.authorNameInternal, + body: (!self.variant.isInfoMessage ? + self.body : + // Info messages might not have a body so we should use the 'previewText' value instead + Interaction.previewText( + variant: self.variant, + body: self.body, + authorDisplayName: authorDisplayName, + attachmentDescriptionInfo: self.attachments?.first.map { firstAttachment in + Attachment.DescriptionInfo( + id: firstAttachment.id, + variant: firstAttachment.variant, + contentType: firstAttachment.contentType, + sourceFilename: firstAttachment.sourceFilename + ) + }, + attachmentCount: self.attachments?.count, + isOpenGroupInvitation: (self.linkPreview?.variant == .openGroupInvitation) + ) + ), + expiresStartedAtMs: self.expiresStartedAtMs, + expiresInSeconds: self.expiresInSeconds, + state: self.state, + hasAtLeastOneReadReceipt: self.hasAtLeastOneReadReceipt, + mostRecentFailureText: self.mostRecentFailureText, + isTypingIndicator: self.isTypingIndicator, + isSenderOpenGroupModerator: self.isSenderOpenGroupModerator, + profile: self.profile, + quote: self.quote, + quoteAttachment: self.quoteAttachment, + linkPreview: self.linkPreview, + linkPreviewAttachment: self.linkPreviewAttachment, + attachments: self.attachments, + cellType: cellType, + authorName: authorDisplayName, + senderName: { + // Only show for group threads + guard self.threadVariant == .openGroup || self.threadVariant == .closedGroup else { + return nil + } + + // Only if there is a date header or the senders are different + guard shouldShowDateOnThisModel || self.authorId != prevModel?.authorId else { + return nil + } + + return authorDisplayName + }(), + shouldShowProfile: ( + // Only group threads + (self.threadVariant == .openGroup || self.threadVariant == .closedGroup) && + + // Only incoming messages + (self.variant == .standardIncoming || self.variant == .standardIncomingDeleted) && + + // Show if the next message has a different sender or has a "date break" + ( + self.authorId != nextModel?.authorId || + shouldShowDateOnNextModel + ) && + + // Need a profile to be able to show it + self.profile != nil + ), + dateForUI: (shouldShowDateOnThisModel ? + Date(timeIntervalSince1970: (TimeInterval(self.timestampMs) / 1000)) : + nil + ), + previousVariant: prevModel?.variant, + positionInCluster: positionInCluster, + isOnlyMessageInCluster: isOnlyMessageInCluster, + isLast: isLast + ) + } + } + + public struct AttachmentInteractionInfo: FetchableRecordWithRowId, Decodable, Identifiable, Equatable, Comparable { + public static let rowIdKey: SQL = SQL(stringLiteral: CodingKeys.rowId.stringValue) + public static let attachmentKey: SQL = SQL(stringLiteral: CodingKeys.attachment.stringValue) + public static let interactionAttachmentKey: SQL = SQL(stringLiteral: CodingKeys.interactionAttachment.stringValue) + + public static let attachmentString: String = CodingKeys.attachment.stringValue + public static let interactionAttachmentString: String = CodingKeys.interactionAttachment.stringValue + + public let rowId: Int64 + public let attachment: Attachment + public let interactionAttachment: InteractionAttachment + + // MARK: - Identifiable + + public var id: String { + "\(interactionAttachment.interactionId)-\(interactionAttachment.albumIndex)" + } + + // MARK: - Comparable + + public static func < (lhs: AttachmentInteractionInfo, rhs: AttachmentInteractionInfo) -> Bool { + return (lhs.interactionAttachment.albumIndex < rhs.interactionAttachment.albumIndex) + } + } +} + +// MARK: - ConversationVC + +extension MessageCell.ViewModel { + public static func filterSQL(threadId: String) -> SQL { + let interaction: TypedTableAlias = TypedTableAlias() + + return SQL("\(interaction[.threadId]) = \(threadId)") + } + + public static let orderSQL: SQL = { + let interaction: TypedTableAlias = TypedTableAlias() + + return SQL("\(interaction[.timestampMs].desc)") + }() + + public static func baseQuery(orderSQL: SQL, baseFilterSQL: SQL) -> ((SQL?, SQL?) -> AdaptedFetchRequest>) { + return { additionalFilters, limitSQL -> AdaptedFetchRequest> in + let interaction: TypedTableAlias = TypedTableAlias() + let thread: TypedTableAlias = TypedTableAlias() + let contact: TypedTableAlias = TypedTableAlias() + let disappearingMessagesConfig: TypedTableAlias = TypedTableAlias() + let profile: TypedTableAlias = TypedTableAlias() + let quote: TypedTableAlias = TypedTableAlias() + let linkPreview: TypedTableAlias = TypedTableAlias() + + let interactionStateTableLiteral: SQL = SQL(stringLiteral: "interactionState") + let interactionStateInteractionIdColumnLiteral: SQL = SQL(stringLiteral: RecipientState.Columns.interactionId.name) + let interactionStateStateColumnLiteral: SQL = SQL(stringLiteral: RecipientState.Columns.state.name) + let interactionStateMostRecentFailureTextColumnLiteral: SQL = SQL(stringLiteral: RecipientState.Columns.mostRecentFailureText.name) + let readReceiptTableLiteral: SQL = SQL(stringLiteral: "readReceipt") + let readReceiptReadTimestampMsColumnLiteral: SQL = SQL(stringLiteral: RecipientState.Columns.readTimestampMs.name) + let attachmentIdColumnLiteral: SQL = SQL(stringLiteral: Attachment.Columns.id.name) + + let finalFilterSQL: SQL = { + guard let additionalFilters: SQL = additionalFilters else { + return """ + WHERE \(baseFilterSQL) + """ + } + + return """ + WHERE ( + \(baseFilterSQL) AND + \(additionalFilters) + ) + """ + }() + let finalLimitSQL: SQL = (limitSQL ?? SQL(stringLiteral: "")) + let numColumnsBeforeLinkedRecords: Int = 17 + let request: SQLRequest = """ + SELECT + \(thread[.variant]) AS \(ViewModel.threadVariantKey), + -- Default to 'true' for non-contact threads + IFNULL(\(contact[.isTrusted]), true) AS \(ViewModel.threadIsTrustedKey), + -- Default to 'false' when no contact exists + IFNULL(\(disappearingMessagesConfig[.isEnabled]), false) AS \(ViewModel.threadHasDisappearingMessagesEnabledKey), + + \(interaction.alias[Column.rowID]) AS \(ViewModel.rowIdKey), + \(interaction[.id]), + \(interaction[.variant]), + \(interaction[.timestampMs]), + \(interaction[.authorId]), + IFNULL(\(profile[.nickname]), \(profile[.name])) AS \(ViewModel.authorNameInternalKey), + \(interaction[.body]), + \(interaction[.expiresStartedAtMs]), + \(interaction[.expiresInSeconds]), + + -- Default to 'sending' assuming non-processed interaction when null + IFNULL(\(interactionStateTableLiteral).\(interactionStateStateColumnLiteral), \(SQL("\(RecipientState.State.sending)"))) AS \(ViewModel.stateKey), + (\(readReceiptTableLiteral).\(readReceiptReadTimestampMsColumnLiteral) IS NOT NULL) AS \(ViewModel.hasAtLeastOneReadReceiptKey), + \(interactionStateTableLiteral).\(interactionStateMostRecentFailureTextColumnLiteral) AS \(ViewModel.mostRecentFailureTextKey), + + false AS \(ViewModel.isTypingIndicatorKey), + false AS \(ViewModel.isSenderOpenGroupModeratorKey), + + \(ViewModel.profileKey).*, + \(ViewModel.quoteKey).*, + \(ViewModel.quoteAttachmentKey).*, + \(ViewModel.linkPreviewKey).*, + \(ViewModel.linkPreviewAttachmentKey).*, + + -- All of the below properties are set in post-query processing but to prevent the + -- query from crashing when decoding we need to provide default values + \(CellType.textOnlyMessage) AS \(ViewModel.cellTypeKey), + '' AS \(ViewModel.authorNameKey), + false AS \(ViewModel.shouldShowProfileKey), + \(Position.middle) AS \(ViewModel.positionInClusterKey), + false AS \(ViewModel.isOnlyMessageInClusterKey), + false AS \(ViewModel.isLastKey) + + FROM \(Interaction.self) + JOIN \(SessionThread.self) ON \(thread[.id]) = \(interaction[.threadId]) + LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(interaction[.threadId]) + LEFT JOIN \(DisappearingMessagesConfiguration.self) ON \(disappearingMessagesConfig[.threadId]) = \(interaction[.threadId]) + LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(interaction[.authorId]) + LEFT JOIN \(Quote.self) ON \(quote[.interactionId]) = \(interaction[.id]) + LEFT JOIN \(Attachment.self) AS \(ViewModel.quoteAttachmentKey) ON \(ViewModel.quoteAttachmentKey).\(attachmentIdColumnLiteral) = \(quote[.attachmentId]) + LEFT JOIN \(LinkPreview.self) ON ( + \(linkPreview[.url]) = \(interaction[.linkPreviewUrl]) AND + \(Interaction.linkPreviewFilterLiteral) + ) + LEFT JOIN \(Attachment.self) AS \(ViewModel.linkPreviewAttachmentKey) ON \(ViewModel.linkPreviewAttachmentKey).\(attachmentIdColumnLiteral) = \(linkPreview[.attachmentId]) + LEFT JOIN ( + \(RecipientState.selectInteractionState( + tableLiteral: interactionStateTableLiteral, + idColumnLiteral: interactionStateInteractionIdColumnLiteral + )) + ) AS \(interactionStateTableLiteral) ON \(interactionStateTableLiteral).\(interactionStateInteractionIdColumnLiteral) = \(interaction[.id]) + LEFT JOIN \(RecipientState.self) AS \(readReceiptTableLiteral) ON ( + \(readReceiptTableLiteral).\(readReceiptReadTimestampMsColumnLiteral) IS NOT NULL AND + \(interaction[.id]) = \(readReceiptTableLiteral).\(interactionStateInteractionIdColumnLiteral) + ) + \(finalFilterSQL) + ORDER BY \(orderSQL) + \(finalLimitSQL) + """ + + return request.adapted { db in + let adapters = try splittingRowAdapters(columnCounts: [ + numColumnsBeforeLinkedRecords, + Profile.numberOfSelectedColumns(db), + Quote.numberOfSelectedColumns(db), + Attachment.numberOfSelectedColumns(db), + LinkPreview.numberOfSelectedColumns(db), + Attachment.numberOfSelectedColumns(db) + ]) + + return ScopeAdapter([ + ViewModel.profileString: adapters[1], + ViewModel.quoteString: adapters[2], + ViewModel.quoteAttachmentString: adapters[3], + ViewModel.linkPreviewString: adapters[4], + ViewModel.linkPreviewAttachmentString: adapters[5] + ]) + } + } + } +} + +extension MessageCell.AttachmentInteractionInfo { + public static let baseQuery: ((SQL?) -> AdaptedFetchRequest>) = { + return { additionalFilters -> AdaptedFetchRequest> in + let attachment: TypedTableAlias = TypedTableAlias() + let interactionAttachment: TypedTableAlias = TypedTableAlias() + + let finalFilterSQL: SQL = { + guard let additionalFilters: SQL = additionalFilters else { + return SQL(stringLiteral: "") + } + + return """ + WHERE \(additionalFilters) + """ + }() + let numColumnsBeforeLinkedRecords: Int = 1 + let request: SQLRequest = """ + SELECT + \(attachment.alias[Column.rowID]) AS \(AttachmentInteractionInfo.rowIdKey), + \(AttachmentInteractionInfo.attachmentKey).*, + \(AttachmentInteractionInfo.interactionAttachmentKey).* + FROM \(Attachment.self) + JOIN \(InteractionAttachment.self) ON \(interactionAttachment[.attachmentId]) = \(attachment[.id]) + \(finalFilterSQL) + """ + + return request.adapted { db in + let adapters = try splittingRowAdapters(columnCounts: [ + numColumnsBeforeLinkedRecords, + Attachment.numberOfSelectedColumns(db), + InteractionAttachment.numberOfSelectedColumns(db) + ]) + + return ScopeAdapter([ + AttachmentInteractionInfo.attachmentString: adapters[1], + AttachmentInteractionInfo.interactionAttachmentString: adapters[2] + ]) + } + } + }() + + public static var joinToViewModelQuerySQL: SQL = { + let interaction: TypedTableAlias = TypedTableAlias() + let attachment: TypedTableAlias = TypedTableAlias() + let interactionAttachment: TypedTableAlias = TypedTableAlias() + + return """ + JOIN \(InteractionAttachment.self) ON \(interactionAttachment[.attachmentId]) = \(attachment[.id]) + JOIN \(Interaction.self) ON + \(interaction[.id]) = \(interactionAttachment[.interactionId]) + """ + }() + + public static func createAssociateDataClosure() -> (DataCache, DataCache) -> DataCache { + return { dataCache, pagedDataCache -> DataCache in + var updatedPagedDataCache: DataCache = pagedDataCache + + dataCache + .values + .grouped(by: \.interactionAttachment.interactionId) + .forEach { (interactionId: Int64, attachments: [MessageCell.AttachmentInteractionInfo]) in + guard + let interactionRowId: Int64 = updatedPagedDataCache.lookup[interactionId], + let dataToUpdate: ViewModel = updatedPagedDataCache.data[interactionRowId] + else { return } + + updatedPagedDataCache = updatedPagedDataCache.upserting( + dataToUpdate.with( + attachments: attachments + .sorted() + .map { $0.attachment } + ) + ) + } + + return updatedPagedDataCache + } + } +} diff --git a/Session/Conversations/Message Cells/TypingIndicatorCell.swift b/Session/Conversations/Message Cells/TypingIndicatorCell.swift index 3650fbf39..d95a445cb 100644 --- a/Session/Conversations/Message Cells/TypingIndicatorCell.swift +++ b/Session/Conversations/Message Cells/TypingIndicatorCell.swift @@ -39,10 +39,10 @@ final class TypingIndicatorCell: MessageCell { // MARK: - Updating - override func update(with item: ConversationViewModel.Item, mediaCache: NSCache, playbackInfo: ConversationViewModel.PlaybackInfo?, lastSearchText: String?) { - guard item.cellType == .typingIndicator else { return } + override func update(with cellViewModel: MessageCell.ViewModel, mediaCache: NSCache, playbackInfo: ConversationViewModel.PlaybackInfo?, lastSearchText: String?) { + guard cellViewModel.cellType == .typingIndicator else { return } - self.item = item + self.viewModel = cellViewModel // Bubble view updateBubbleViewCorners() @@ -51,7 +51,7 @@ final class TypingIndicatorCell: MessageCell { typingIndicatorView.startAnimation() } - override func dynamicUpdate(with item: ConversationViewModel.Item, playbackInfo: ConversationViewModel.PlaybackInfo?) { + override func dynamicUpdate(with cellViewModel: MessageCell.ViewModel, playbackInfo: ConversationViewModel.PlaybackInfo?) { } override func layoutSubviews() { @@ -82,9 +82,9 @@ final class TypingIndicatorCell: MessageCell { // MARK: - Convenience private func getCornersToRound() -> UIRectCorner { - guard item?.isOnlyMessageInCluster == false else { return .allCorners } + guard viewModel?.isOnlyMessageInCluster == false else { return .allCorners } - switch item?.positionInCluster { + switch viewModel?.positionInCluster { case .top: return [ .topLeft, .topRight, .bottomRight ] case .middle: return [ .topRight, .bottomRight ] case .bottom: return [ .topRight, .bottomRight, .bottomLeft ] diff --git a/Session/Conversations/Message Cells/VisibleMessageCell.swift b/Session/Conversations/Message Cells/VisibleMessageCell.swift index cdbfade3c..96410cadf 100644 --- a/Session/Conversations/Message Cells/VisibleMessageCell.swift +++ b/Session/Conversations/Message Cells/VisibleMessageCell.swift @@ -35,8 +35,6 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel result.delegate = self return result }() - - var lastSearchedText: String? { delegate?.lastSearchedText } // MARK: - UI Components @@ -200,79 +198,80 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel // MARK: - Updating override func update( - with item: ConversationViewModel.Item, + with cellViewModel: MessageCell.ViewModel, mediaCache: NSCache, playbackInfo: ConversationViewModel.PlaybackInfo?, lastSearchText: String? ) { - self.item = item + self.viewModel = cellViewModel - let isGroupThread: Bool = (item.threadVariant == .openGroup || item.threadVariant == .closedGroup) + let isGroupThread: Bool = (cellViewModel.threadVariant == .openGroup || cellViewModel.threadVariant == .closedGroup) let shouldInsetHeader: Bool = ( - item.previousInteractionVariant?.isInfoMessage != true && + cellViewModel.previousVariant?.isInfoMessage != true && ( - item.positionInCluster == .top || - item.isOnlyMessageInCluster + cellViewModel.positionInCluster == .top || + cellViewModel.isOnlyMessageInCluster ) ) // Profile picture view profilePictureViewLeftConstraint.constant = (isGroupThread ? VisibleMessageCell.groupThreadHSpacing : 0) profilePictureViewWidthConstraint.constant = (isGroupThread ? VisibleMessageCell.profilePictureSize : 0) - profilePictureView.isHidden = (!item.shouldShowProfile || item.profile == nil) + profilePictureView.isHidden = (!cellViewModel.shouldShowProfile || cellViewModel.profile == nil) profilePictureView.update( - publicKey: item.authorId, - profile: item.profile, - threadVariant: item.threadVariant + publicKey: cellViewModel.authorId, + profile: cellViewModel.profile, + threadVariant: cellViewModel.threadVariant ) - moderatorIconImageView.isHidden = !item.isSenderOpenGroupModerator + moderatorIconImageView.isHidden = !cellViewModel.isSenderOpenGroupModerator // Bubble view bubbleViewLeftConstraint1.isActive = ( - item.interactionVariant == .standardIncoming || - item.interactionVariant == .standardIncomingDeleted + cellViewModel.variant == .standardIncoming || + cellViewModel.variant == .standardIncomingDeleted ) bubbleViewLeftConstraint1.constant = (isGroupThread ? VisibleMessageCell.groupThreadHSpacing : VisibleMessageCell.contactThreadHSpacing) - bubbleViewLeftConstraint2.isActive = (item.interactionVariant == .standardOutgoing) - bubbleViewTopConstraint.constant = (item.senderName == nil ? 0 : VisibleMessageCell.authorLabelBottomSpacing) - bubbleViewRightConstraint1.isActive = (item.interactionVariant == .standardOutgoing) + bubbleViewLeftConstraint2.isActive = (cellViewModel.variant == .standardOutgoing) + bubbleViewTopConstraint.constant = (cellViewModel.senderName == nil ? 0 : VisibleMessageCell.authorLabelBottomSpacing) + bubbleViewRightConstraint1.isActive = (cellViewModel.variant == .standardOutgoing) bubbleViewRightConstraint2.isActive = ( - item.interactionVariant == .standardIncoming || - item.interactionVariant == .standardIncomingDeleted + cellViewModel.variant == .standardIncoming || + cellViewModel.variant == .standardIncomingDeleted ) bubbleView.backgroundColor = (( - item.interactionVariant == .standardIncoming || - item.interactionVariant == .standardIncomingDeleted + cellViewModel.variant == .standardIncoming || + cellViewModel.variant == .standardIncomingDeleted ) ? Colors.receivedMessageBackground : Colors.sentMessageBackground) updateBubbleViewCorners() // Content view - populateContentView(for: item, mediaCache: mediaCache, playbackInfo: playbackInfo, lastSearchText: lastSearchText) + populateContentView(for: cellViewModel, mediaCache: mediaCache, playbackInfo: playbackInfo, lastSearchText: lastSearchText) // Date break headerViewTopConstraint.constant = (shouldInsetHeader ? Values.mediumSpacing : 1) headerView.subviews.forEach { $0.removeFromSuperview() } - populateHeader(for: item, shouldInsetHeader: shouldInsetHeader) + populateHeader(for: cellViewModel, shouldInsetHeader: shouldInsetHeader) // Author label authorLabel.textColor = Colors.text - authorLabel.isHidden = (item.senderName == nil) - authorLabel.text = item.senderName + authorLabel.isHidden = (cellViewModel.senderName == nil) + authorLabel.text = cellViewModel.senderName - let authorLabelAvailableWidth: CGFloat = (VisibleMessageCell.getMaxWidth(for: item) - 2 * VisibleMessageCell.authorLabelInset) + let authorLabelAvailableWidth: CGFloat = (VisibleMessageCell.getMaxWidth(for: cellViewModel) - 2 * VisibleMessageCell.authorLabelInset) let authorLabelAvailableSpace = CGSize(width: authorLabelAvailableWidth, height: .greatestFiniteMagnitude) let authorLabelSize = authorLabel.sizeThatFits(authorLabelAvailableSpace) - authorLabelHeightConstraint.constant = (item.senderName != nil ? authorLabelSize.height : 0) + authorLabelHeightConstraint.constant = (cellViewModel.senderName != nil ? authorLabelSize.height : 0) // Message status image view - let (image, tintColor, backgroundColor) = getMessageStatusImage(for: item) + let (image, tintColor, backgroundColor) = getMessageStatusImage(for: cellViewModel) messageStatusImageView.image = image messageStatusImageView.tintColor = tintColor messageStatusImageView.backgroundColor = backgroundColor messageStatusImageView.isHidden = ( - item.interactionVariant != .standardOutgoing || ( - item.state == .sent && - item.isLastInteraction + cellViewModel.variant != .standardOutgoing || + ( + cellViewModel.state == .sent && + !cellViewModel.isLast ) ) messageStatusImageViewTopConstraint.constant = (messageStatusImageView.isHidden ? 0 : 5) @@ -283,9 +282,8 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel // Timer if - item.isExpiringMessage, - let expiresStartedAtMs: Double = item.expiresStartedAtMs, - let expiresInSeconds: TimeInterval = item.expiresInSeconds + let expiresStartedAtMs: Double = cellViewModel.expiresStartedAtMs, + let expiresInSeconds: TimeInterval = cellViewModel.expiresInSeconds { let expirationTimestampMs: Double = (expiresStartedAtMs + (expiresInSeconds * 1000)) @@ -294,17 +292,20 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel initialDurationSeconds: UInt32(floor(expiresInSeconds)), tintColor: Colors.text ) + timerView.isHidden = false + } + else { + timerView.isHidden = true } - timerView.isHidden = !item.isExpiringMessage - timerViewOutgoingMessageConstraint.isActive = (item.interactionVariant == .standardOutgoing) + timerViewOutgoingMessageConstraint.isActive = (cellViewModel.variant == .standardOutgoing) timerViewIncomingMessageConstraint.isActive = ( - item.interactionVariant == .standardIncoming || - item.interactionVariant == .standardIncomingDeleted + cellViewModel.variant == .standardIncoming || + cellViewModel.variant == .standardIncomingDeleted ) // Swipe to reply - if item.interactionVariant == .standardIncomingDeleted { + if cellViewModel.variant == .standardIncomingDeleted { removeGestureRecognizer(panGestureRecognizer) } else { @@ -312,8 +313,8 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel } } - private func populateHeader(for item: ConversationViewModel.Item, shouldInsetHeader: Bool) { - guard let date: Date = item.dateForUI else { return } + private func populateHeader(for cellViewModel: MessageCell.ViewModel, shouldInsetHeader: Bool) { + guard let date: Date = cellViewModel.dateForUI else { return } let dateBreakLabel: UILabel = UILabel() dateBreakLabel.font = .boldSystemFont(ofSize: Values.verySmallFontSize) @@ -329,20 +330,20 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel headerView.pin(.bottom, to: .bottom, of: dateBreakLabel, withInset: Values.smallSpacing + additionalBottomInset) dateBreakLabel.center(.horizontal, in: headerView) - let availableWidth = VisibleMessageCell.getMaxWidth(for: item) + let availableWidth = VisibleMessageCell.getMaxWidth(for: cellViewModel) let availableSpace = CGSize(width: availableWidth, height: .greatestFiniteMagnitude) let dateBreakLabelSize = dateBreakLabel.sizeThatFits(availableSpace) dateBreakLabel.set(.height, to: dateBreakLabelSize.height) } private func populateContentView( - for item: ConversationViewModel.Item, + for cellViewModel: MessageCell.ViewModel, mediaCache: NSCache, playbackInfo: ConversationViewModel.PlaybackInfo?, lastSearchText: String? ) { let bodyLabelTextColor: UIColor = { - let direction: Direction = (item.interactionVariant == .standardOutgoing ? + let direction: Direction = (cellViewModel.variant == .standardOutgoing ? .outgoing : .incoming ) @@ -359,7 +360,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel bodyTextView = nil // Handle the deleted state first (it's much simpler than the others) - guard item.interactionVariant != .standardIncomingDeleted else { + guard cellViewModel.variant != .standardIncomingDeleted else { let deletedMessageView: DeletedMessageView = DeletedMessageView(textColor: bodyLabelTextColor) snContentView.addSubview(deletedMessageView) deletedMessageView.pin(to: snContentView) @@ -367,32 +368,32 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel } // If it's an incoming media message and the thread isn't trusted then show the placeholder view - if item.cellType != .textOnlyMessage && item.interactionVariant == .standardIncoming && !item.isThreadTrusted { - let mediaPlaceholderView = MediaPlaceholderView(item: item, textColor: bodyLabelTextColor) + if cellViewModel.cellType != .textOnlyMessage && cellViewModel.variant == .standardIncoming && !cellViewModel.threadIsTrusted { + let mediaPlaceholderView = MediaPlaceholderView(cellViewModel: cellViewModel, textColor: bodyLabelTextColor) snContentView.addSubview(mediaPlaceholderView) mediaPlaceholderView.pin(to: snContentView) return } - switch item.cellType { + switch cellViewModel.cellType { case .typingIndicator: break case .textOnlyMessage: let inset: CGFloat = 12 - let maxWidth: CGFloat = (VisibleMessageCell.getMaxWidth(for: item) - 2 * inset) + let maxWidth: CGFloat = (VisibleMessageCell.getMaxWidth(for: cellViewModel) - 2 * inset) - if let linkPreview: LinkPreview = item.linkPreview { + if let linkPreview: LinkPreview = cellViewModel.linkPreview { switch linkPreview.variant { case .standard: let linkPreviewView: LinkPreviewView = LinkPreviewView(maxWidth: maxWidth) linkPreviewView.update( with: LinkPreview.SentState( linkPreview: linkPreview, - imageAttachment: item.attachments?.first + imageAttachment: cellViewModel.linkPreviewAttachment ), - isOutgoing: (item.interactionVariant == .standardOutgoing), + isOutgoing: (cellViewModel.variant == .standardOutgoing), delegate: self, - item: item, + cellViewModel: cellViewModel, bodyLabelTextColor: bodyLabelTextColor, lastSearchText: lastSearchText ) @@ -406,7 +407,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel name: (linkPreview.title ?? ""), url: linkPreview.url, textColor: bodyLabelTextColor, - isOutgoing: (item.interactionVariant == .standardOutgoing) + isOutgoing: (cellViewModel.variant == .standardOutgoing) ) snContentView.addSubview(openGroupInvitationView) @@ -421,18 +422,18 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel stackView.spacing = 2 // Quote view - if let quote: Quote = item.quote { + if let quote: Quote = cellViewModel.quote { let hInset: CGFloat = 2 let quoteView: QuoteView = QuoteView( for: .regular, authorId: quote.authorId, quotedText: quote.body, - threadVariant: item.threadVariant, - direction: (item.interactionVariant == .standardOutgoing ? + threadVariant: cellViewModel.threadVariant, + direction: (cellViewModel.variant == .standardOutgoing ? .outgoing : .incoming ), - attachment: item.attachments?.first, + attachment: cellViewModel.quoteAttachment, hInset: hInset, maxWidth: maxWidth ) @@ -441,7 +442,13 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel } // Body text view - let bodyTextView = VisibleMessageCell.getBodyTextView(for: item, with: maxWidth, textColor: bodyLabelTextColor, searchText: lastSearchText, delegate: self) + let bodyTextView = VisibleMessageCell.getBodyTextView( + for: cellViewModel, + with: maxWidth, + textColor: bodyLabelTextColor, + searchText: lastSearchText, + delegate: self + ) self.bodyTextView = bodyTextView stackView.addArrangedSubview(bodyTextView) @@ -457,17 +464,17 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel stackView.spacing = Values.smallSpacing // Album view - let maxMessageWidth: CGFloat = VisibleMessageCell.getMaxWidth(for: item) + let maxMessageWidth: CGFloat = VisibleMessageCell.getMaxWidth(for: cellViewModel) let albumView = MediaAlbumView( mediaCache: mediaCache, - items: (item.attachments? + items: (cellViewModel.attachments? .filter { $0.isVisualMedia }) .defaulting(to: []), - isOutgoing: (item.interactionVariant == .standardOutgoing), + isOutgoing: (cellViewModel.variant == .standardOutgoing), maxMessageWidth: maxMessageWidth ) self.albumView = albumView - let size = getSize(for: item) + let size = getSize(for: cellViewModel) albumView.set(.width, to: size.width) albumView.set(.height, to: size.height) albumView.loadMedia() @@ -475,10 +482,16 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel stackView.addArrangedSubview(albumView) // Body text view - if let body: String = item.body, !body.isEmpty { + if let body: String = cellViewModel.body, !body.isEmpty { let inset: CGFloat = 12 let maxWidth = size.width - 2 * inset - let bodyTextView = VisibleMessageCell.getBodyTextView(for: item, with: maxWidth, textColor: bodyLabelTextColor, searchText: lastSearchText, delegate: self) + let bodyTextView = VisibleMessageCell.getBodyTextView( + for: cellViewModel, + with: maxWidth, + textColor: bodyLabelTextColor, + searchText: lastSearchText, + delegate: self + ) self.bodyTextView = bodyTextView stackView.addArrangedSubview(UIView(wrapping: bodyTextView, withInsets: UIEdgeInsets(top: 0, left: inset, bottom: inset, right: inset))) } @@ -489,7 +502,9 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel stackView.pin(to: snContentView) case .audio: - guard let attachment: Attachment = item.attachments?.first(where: { $0.isAudio }) else { return } + guard let attachment: Attachment = cellViewModel.attachments?.first(where: { $0.isAudio }) else { + return + } let voiceMessageView: VoiceMessageView = VoiceMessageView() voiceMessageView.update( @@ -506,10 +521,10 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel self.voiceMessageView = voiceMessageView case .genericAttachment: - guard let attachment: Attachment = item.attachments?.first else { preconditionFailure() } + guard let attachment: Attachment = cellViewModel.attachments?.first else { preconditionFailure() } let inset: CGFloat = 12 - let maxWidth = (VisibleMessageCell.getMaxWidth(for: item) - 2 * inset) + let maxWidth = (VisibleMessageCell.getMaxWidth(for: cellViewModel) - 2 * inset) // Stack view let stackView = UIStackView(arrangedSubviews: []) @@ -521,8 +536,14 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel stackView.addArrangedSubview(documentView) // Body text view - if let body: String = item.body, !body.isEmpty { // delegate should always be set at this point - let bodyTextView = VisibleMessageCell.getBodyTextView(for: item, with: maxWidth, textColor: bodyLabelTextColor, searchText: lastSearchText, delegate: self) + if let body: String = cellViewModel.body, !body.isEmpty { // delegate should always be set at this point + let bodyTextView = VisibleMessageCell.getBodyTextView( + for: cellViewModel, + with: maxWidth, + textColor: bodyLabelTextColor, + searchText: lastSearchText, + delegate: self + ) self.bodyTextView = bodyTextView stackView.addArrangedSubview(bodyTextView) } @@ -554,17 +575,19 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel bubbleView.layer.maskedCorners = getCornerMask(from: cornersToRound) } - override func dynamicUpdate(with item: ConversationViewModel.Item, playbackInfo: ConversationViewModel.PlaybackInfo?) { - guard item.interactionVariant != .standardIncomingDeleted else { return } + override func dynamicUpdate(with cellViewModel: MessageCell.ViewModel, playbackInfo: ConversationViewModel.PlaybackInfo?) { + guard cellViewModel.variant != .standardIncomingDeleted else { return } // If it's an incoming media message and the thread isn't trusted then show the placeholder view - if item.cellType != .textOnlyMessage && item.interactionVariant == .standardIncoming && !item.isThreadTrusted { + if cellViewModel.cellType != .textOnlyMessage && cellViewModel.variant == .standardIncoming && !cellViewModel.threadIsTrusted { return } - switch item.cellType { + switch cellViewModel.cellType { case .audio: - guard let attachment: Attachment = item.attachments?.first(where: { $0.isAudio }) else { return } + guard let attachment: Attachment = cellViewModel.attachments?.first(where: { $0.isAudio }) else { + return + } self.voiceMessageView?.update( with: attachment, @@ -631,17 +654,17 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel } @objc func handleLongPress() { - guard let item: ConversationViewModel.Item = self.item else { return } + guard let cellViewModel: MessageCell.ViewModel = self.viewModel else { return } - delegate?.handleItemLongPressed(item) + delegate?.handleItemLongPressed(cellViewModel) } @objc private func handleTap(_ gestureRecognizer: UITapGestureRecognizer) { - guard let item: ConversationViewModel.Item = self.item else { return } + guard let cellViewModel: MessageCell.ViewModel = self.viewModel else { return } let location = gestureRecognizer.location(in: self) - if profilePictureView.frame.contains(location), let profile: Profile = item.profile, item.threadVariant != .openGroup { + if profilePictureView.frame.contains(location), let profile: Profile = cellViewModel.profile, cellViewModel.threadVariant != .openGroup { delegate?.showUserDetails(for: profile) } else if replyButton.alpha > 0 && replyButton.frame.contains(location) { @@ -649,18 +672,18 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel reply() } else if bubbleView.frame.contains(location) { - delegate?.handleItemTapped(item, gestureRecognizer: gestureRecognizer) + delegate?.handleItemTapped(cellViewModel, gestureRecognizer: gestureRecognizer) } } @objc private func handleDoubleTap() { - guard let item: ConversationViewModel.Item = self.item else { return } + guard let cellViewModel: MessageCell.ViewModel = self.viewModel else { return } - delegate?.handleItemDoubleTapped(item) + delegate?.handleItemDoubleTapped(cellViewModel) } @objc private func handlePan(_ gestureRecognizer: UIPanGestureRecognizer) { - guard let item: ConversationViewModel.Item = self.item else { return } + guard let cellViewModel: MessageCell.ViewModel = self.viewModel else { return } let viewsToMove: [UIView] = [ bubbleView, profilePictureView, replyButton, timerView, messageStatusImageView @@ -668,7 +691,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel let translationX = gestureRecognizer.translation(in: self).x.clamp(-CGFloat.greatestFiniteMagnitude, 0) switch gestureRecognizer.state { - case .began: delegate?.handleItemSwiped(item, state: .began) + case .began: delegate?.handleItemSwiped(cellViewModel, state: .began) case .changed: // The idea here is to asymptotically approach a maximum drag distance @@ -688,11 +711,11 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel case .ended, .cancelled: if abs(translationX) > VisibleMessageCell.swipeToReplyThreshold { - delegate?.handleItemSwiped(item, state: .ended) + delegate?.handleItemSwiped(cellViewModel, state: .ended) reply() } else { - delegate?.handleItemSwiped(item, state: .cancelled) + delegate?.handleItemSwiped(cellViewModel, state: .cancelled) resetReply() } @@ -722,20 +745,20 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel } private func reply() { - guard let item: ConversationViewModel.Item = self.item else { return } + guard let cellViewModel: MessageCell.ViewModel = self.viewModel else { return } resetReply() - delegate?.handleReplyButtonTapped(for: item) + delegate?.handleReplyButtonTapped(for: cellViewModel) } // MARK: - Convenience private func getCornersToRound() -> UIRectCorner { - guard item?.isOnlyMessageInCluster == false else { return .allCorners } + guard viewModel?.isOnlyMessageInCluster == false else { return .allCorners } - let direction: Direction = (item?.interactionVariant == .standardOutgoing ? .outgoing : .incoming) + let direction: Direction = (viewModel?.variant == .standardOutgoing ? .outgoing : .incoming) - switch (item?.positionInCluster, direction) { + switch (viewModel?.positionInCluster, direction) { case (.top, .outgoing): return [ .bottomLeft, .topLeft, .topRight ] case (.middle, .outgoing): return [ .bottomLeft, .topLeft ] case (.bottom, .outgoing): return [ .bottomRight, .bottomLeft, .topLeft ] @@ -759,7 +782,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel return cornerMask } - private static func getFontSize(for item: ConversationViewModel.Item) -> CGFloat { + private static func getFontSize(for cellViewModel: MessageCell.ViewModel) -> CGFloat { let baselineFontSize = Values.mediumFontSize switch viewItem.displayableBodyText?.jumbomojiCount { case 1: return baselineFontSize + 30 @@ -769,14 +792,14 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel } } - private func getMessageStatusImage(for item: ConversationViewModel.Item) -> (image: UIImage?, tintColor: UIColor?, backgroundColor: UIColor?) { - guard item.interactionVariant == .standardOutgoing else { return (nil, nil, nil) } + private func getMessageStatusImage(for cellViewModel: MessageCell.ViewModel) -> (image: UIImage?, tintColor: UIColor?, backgroundColor: UIColor?) { + guard cellViewModel.variant == .standardOutgoing else { return (nil, nil, nil) } let image: UIImage var tintColor: UIColor? = nil var backgroundColor: UIColor? = nil - switch (item.state, item.hasAtLeastOneReadReceipt) { + switch (cellViewModel.state, cellViewModel.hasAtLeastOneReadReceipt) { case (.sending, _): image = #imageLiteral(resourceName: "CircleDotDotDot").withRenderingMode(.alwaysTemplate) tintColor = Colors.text @@ -797,10 +820,12 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel return (image, tintColor, backgroundColor) } - private func getSize(for item: ConversationViewModel.Item) -> CGSize { - guard let mediaAttachments: [Attachment] = item.attachments?.filter({ $0.isVisualMedia }) else { preconditionFailure() } + private func getSize(for cellViewModel: MessageCell.ViewModel) -> CGSize { + guard let mediaAttachments: [Attachment] = cellViewModel.attachments?.filter({ $0.isVisualMedia }) else { + preconditionFailure() + } - let maxMessageWidth = VisibleMessageCell.getMaxWidth(for: item) + let maxMessageWidth = VisibleMessageCell.getMaxWidth(for: cellViewModel) let defaultSize = MediaAlbumView.layoutSize(forMaxMessageWidth: maxMessageWidth, items: mediaAttachments) guard @@ -843,13 +868,16 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel return CGSize(width: width, height: height) } - static func getMaxWidth(for item: ConversationViewModel.Item) -> CGFloat { + static func getMaxWidth(for cellViewModel: MessageCell.ViewModel) -> CGFloat { let screen: CGRect = UIScreen.main.bounds - switch item.interactionVariant { + switch cellViewModel.variant { case .standardOutgoing: return (screen.width - contactThreadHSpacing - gutterSize) case .standardIncoming, .standardIncomingDeleted: - let isGroupThread = (item.threadVariant == .openGroup || item.threadVariant == .closedGroup) + let isGroupThread = ( + cellViewModel.threadVariant == .openGroup || + cellViewModel.threadVariant == .closedGroup + ) let leftGutterSize = (isGroupThread ? gutterSize : contactThreadHSpacing) return (screen.width - leftGutterSize - gutterSize) @@ -859,7 +887,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel } static func getBodyTextView( - for item: ConversationViewModel.Item, + for cellViewModel: MessageCell.ViewModel, with availableWidth: CGFloat, textColor: UIColor, searchText: String?, @@ -872,18 +900,18 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel // // Note: We can't just set 'isSelectable' to false otherwise the link detection/selection // stops working - let isOutgoing: Bool = (item.interactionVariant == .standardOutgoing) + let isOutgoing: Bool = (cellViewModel.variant == .standardOutgoing) let result: BodyTextView = BodyTextView(snDelegate: delegate) result.isEditable = false let attributedText: NSMutableAttributedString = NSMutableAttributedString( attributedString: MentionUtilities.highlightMentions( - in: (item.body ?? ""), - threadVariant: item.threadVariant, + in: (cellViewModel.body ?? ""), + threadVariant: cellViewModel.threadVariant, isOutgoingMessage: isOutgoing, attributes: [ .foregroundColor : textColor, - .font : UIFont.systemFont(ofSize: getFontSize(for: item)) + .font : UIFont.systemFont(ofSize: getFontSize(for: cellViewModel)) ] ) ) diff --git a/Session/Media Viewing & Editing/MediaGalleryViewModel.swift b/Session/Media Viewing & Editing/MediaGalleryViewModel.swift index 7d9c17f02..65aa4c09c 100644 --- a/Session/Media Viewing & Editing/MediaGalleryViewModel.swift +++ b/Session/Media Viewing & Editing/MediaGalleryViewModel.swift @@ -4,8 +4,20 @@ import Foundation import GRDB import DifferenceKit import SignalUtilitiesKit +import SessionUtilitiesKit -public class MediaGalleryViewModel: TransactionObserver { +public class MediaGalleryViewModel { + public typealias SectionModel = ArraySection + + // MARK: - Section + + public enum Section: Differentiable, Equatable, Comparable, Hashable { + case emptyGallery + case loadOlder + case galleryMonth(date: GalleryDate) + case loadNewer + } + public let threadId: String public let threadVariant: SessionThread.Variant private var focusedAttachmentId: String? @@ -18,59 +30,61 @@ public class MediaGalleryViewModel: TransactionObserver { public var interactionIdBefore: [Int64: Int64] { cachedInteractionIdBefore.wrappedValue } public var interactionIdAfter: [Int64: Int64] { cachedInteractionIdAfter.wrappedValue } public private(set) var albumData: [Int64: [Item]] = [:] + public private(set) var pagedDatabaseObserver: PagedDatabaseObserver? /// This value is the current state of a gallery view public private(set) var galleryData: [SectionModel] = [] - - // MARK: - Paging - - public struct PageInfo { - public enum Target: Equatable { - case before - case around(id: String) - case after - } - - let pageSize: Int - let pageOffset: Int - let currentCount: Int - let totalCount: Int - - // MARK: - Initizliation - - init( - pageSize: Int, - pageOffset: Int = 0, - currentCount: Int = 0, - totalCount: Int = 0 - ) { - self.pageSize = pageSize - self.pageOffset = pageOffset - self.currentCount = currentCount - self.totalCount = totalCount - } - } - - private var isFetchingMoreItems: Atomic = Atomic(false) - private var pageInfo: Atomic - - // Gallery observing - - private let updatedRows: Atomic> = Atomic([]) - public var onGalleryChange: (([SectionModel], PageInfo) -> ())? + public var onGalleryChange: (([SectionModel]) -> ())? // MARK: - Initialization init( threadId: String, threadVariant: SessionThread.Variant, + isPagedData: Bool, pageSize: Int = 1, focusedAttachmentId: String? = nil ) { self.threadId = threadId self.threadVariant = threadVariant - self.pageInfo = Atomic(PageInfo(pageSize: pageSize)) self.focusedAttachmentId = focusedAttachmentId + self.pagedDatabaseObserver = nil + + guard isPagedData else { return } + + var hasSavedIntialUpdate: Bool = false + let filterSQL: SQL = Item.filterSQL(threadId: threadId) + self.pagedDatabaseObserver = PagedDatabaseObserver( + pagedTable: Attachment.self, + pageSize: pageSize, + idColumn: .id, + initialFocusedId: focusedAttachmentId, + observedChanges: [ + PagedData.ObservedChanges( + table: Attachment.self, + columns: [.isValid] + ) + ], + joinSQL: Item.joinSQL, + filterSQL: filterSQL, + orderSQL: Item.galleryOrderSQL, + dataQuery: Item.baseQuery(orderSQL: Item.galleryOrderSQL, baseFilterSQL: filterSQL), + onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in + guard let updatedGalleryData: [SectionModel] = self?.process(data: updatedData, for: updatedPageInfo) else { + return + } + + // If we haven't stored the data for the initial fetch then do so now (no need + // to call 'onGalleryChange' in this case as it will always be null) + guard hasSavedIntialUpdate else { + self?.updateGalleryData(updatedGalleryData) + hasSavedIntialUpdate = true + return + } + + self?.onGalleryChange?(updatedGalleryData) + } + ) } // MARK: - Data @@ -132,33 +146,26 @@ public class MediaGalleryViewModel: TransactionObserver { } } - public typealias SectionModel = ArraySection - - public enum Section: Differentiable, Equatable, Comparable, Hashable { - case emptyGallery - case loadNewer - case galleryMonth(date: GalleryDate) - case loadOlder - } - - public struct Item: FetchableRecord, Decodable, Differentiable, Equatable, Hashable, Comparable { - fileprivate static let interactionIdKey: String = CodingKeys.interactionId.stringValue - fileprivate static let interactionVariantKey: String = CodingKeys.interactionVariant.stringValue - fileprivate static let interactionAuthorIdKey: String = CodingKeys.interactionAuthorId.stringValue - fileprivate static let interactionTimestampMsKey: String = CodingKeys.interactionTimestampMs.stringValue - fileprivate static let attachmentRowIdKey: String = CodingKeys.attachmentRowId.stringValue - fileprivate static let attachmentAlbumIndexKey: String = CodingKeys.attachmentAlbumIndex.stringValue + public struct Item: FetchableRecordWithRowId, Decodable, Identifiable, Differentiable, Equatable, Hashable { + fileprivate static let interactionIdKey: SQL = SQL(stringLiteral: CodingKeys.interactionId.stringValue) + fileprivate static let interactionVariantKey: SQL = SQL(stringLiteral: CodingKeys.interactionVariant.stringValue) + fileprivate static let interactionAuthorIdKey: SQL = SQL(stringLiteral: CodingKeys.interactionAuthorId.stringValue) + fileprivate static let interactionTimestampMsKey: SQL = SQL(stringLiteral: CodingKeys.interactionTimestampMs.stringValue) + fileprivate static let rowIdKey: SQL = SQL(stringLiteral: CodingKeys.rowId.stringValue) + fileprivate static let attachmentKey: SQL = SQL(stringLiteral: CodingKeys.attachment.stringValue) + fileprivate static let attachmentAlbumIndexKey: SQL = SQL(stringLiteral: CodingKeys.attachmentAlbumIndex.stringValue) - public var differenceIdentifier: String { - return attachment.id - } + fileprivate static let attachmentString: String = CodingKeys.attachment.stringValue + + public var id: String { attachment.id } + public var differenceIdentifier: String { attachment.id } let interactionId: Int64 let interactionVariant: Interaction.Variant let interactionAuthorId: String let interactionTimestampMs: Int64 - let attachmentRowId: Int64 + public var rowId: Int64 let attachmentAlbumIndex: Int let attachment: Attachment @@ -182,25 +189,31 @@ public class MediaGalleryViewModel: TransactionObserver { var captionForDisplay: String? { attachment.caption?.filterForDisplay } - // MARK: - Comparable - - public static func < (lhs: Item, rhs: Item) -> Bool { - if lhs.interactionTimestampMs == rhs.interactionTimestampMs { - return (lhs.attachmentAlbumIndex < rhs.attachmentAlbumIndex) - } - - return (lhs.interactionTimestampMs < rhs.interactionTimestampMs) - } - // MARK: - Query - private static let baseQueryFilterSQL: SQL = { + fileprivate static let joinSQL: SQL = { let attachment: TypedTableAlias = TypedTableAlias() + let interaction: TypedTableAlias = TypedTableAlias() + let interactionAttachment: TypedTableAlias = TypedTableAlias() - return SQL("\(attachment[.isVisualMedia]) = true AND \(attachment[.isValid]) = true") + return """ + JOIN \(InteractionAttachment.self) ON \(interactionAttachment[.attachmentId]) = \(attachment[.id]) + JOIN \(Interaction.self) ON \(interaction[.id]) = \(interactionAttachment[.interactionId]) + """ }() - private static let galleryQueryOrderSQL: SQL = { + fileprivate static func filterSQL(threadId: String) -> SQL { + let interaction: TypedTableAlias = TypedTableAlias() + let attachment: TypedTableAlias = TypedTableAlias() + + return SQL(""" + \(attachment[.isVisualMedia]) = true AND + \(attachment[.isValid]) = true AND + \(interaction[.threadId]) = \(threadId) + """) + } + + fileprivate static let galleryOrderSQL: SQL = { let interaction: TypedTableAlias = TypedTableAlias() let interactionAttachment: TypedTableAlias = TypedTableAlias() @@ -209,101 +222,72 @@ public class MediaGalleryViewModel: TransactionObserver { return SQL("\(interaction[.timestampMs].desc), \(interactionAttachment[.albumIndex])") }() - /// Retrieve the index that the attachment with the given `attachmentId` will have in the gallery - fileprivate static func galleryIndex(for attachmentId: String) -> SQLRequest { - let attachment: TypedTableAlias = TypedTableAlias() + fileprivate static let galleryReverseOrderSQL: SQL = { let interaction: TypedTableAlias = TypedTableAlias() let interactionAttachment: TypedTableAlias = TypedTableAlias() - return """ - SELECT - (gallery.galleryIndex - 1) AS galleryIndex -- Converting from 1-Indexed to 0-indexed - FROM ( - SELECT - \(attachment[.id]) AS id, - ROW_NUMBER() OVER (ORDER BY \(galleryQueryOrderSQL)) AS galleryIndex - FROM \(Attachment.self) - JOIN \(InteractionAttachment.self) ON \(interactionAttachment[.attachmentId]) = \(attachment[.id]) - JOIN \(Interaction.self) ON \(interaction[.id]) = \(interactionAttachment[.interactionId]) - WHERE \(baseQueryFilterSQL) - ) AS gallery - WHERE \(SQL("gallery.id = \(attachmentId)")) - """ - } + /// **Note:** This **MUST** match the desired sort behaviour for the screen otherwise paging will be + /// very broken + return SQL("\(interaction[.timestampMs]), \(interactionAttachment[.albumIndex].desc)") + }() - /// Retrieve the indexes the given attachment row will have in the gallery - fileprivate static func galleryIndexes(for rowIds: Set, threadId: String) -> SQLRequest { - let attachment: TypedTableAlias = TypedTableAlias() - let interaction: TypedTableAlias = TypedTableAlias() - let interactionAttachment: TypedTableAlias = TypedTableAlias() - - return """ - SELECT - (gallery.galleryIndex - 1) AS galleryIndex -- Converting from 1-Indexed to 0-indexed - FROM ( - SELECT - \(attachment.alias[Column.rowID]) AS rowid, - ROW_NUMBER() OVER (ORDER BY \(galleryQueryOrderSQL)) AS galleryIndex - FROM \(Attachment.self) - JOIN \(InteractionAttachment.self) ON \(interactionAttachment[.attachmentId]) = \(attachment[.id]) - JOIN \(Interaction.self) ON ( - \(interaction[.id]) = \(interactionAttachment[.interactionId]) AND - \(SQL("\(interaction[.threadId]) = \(threadId)")) - ) - WHERE \(baseQueryFilterSQL) - ) AS gallery - WHERE \(SQL("gallery.rowid IN \(rowIds)")) - """ - } - - private static let baseQuery: QueryInterfaceRequest = { - let attachment: TypedTableAlias = TypedTableAlias() - let interaction: TypedTableAlias = TypedTableAlias() - let interactionAttachment: TypedTableAlias = TypedTableAlias() - - return Attachment - .select( - interaction[.id].forKey(Item.interactionIdKey), - interaction[.variant].forKey(Item.interactionVariantKey), - interaction[.authorId].forKey(Item.interactionAuthorIdKey), - interaction[.timestampMs].forKey(Item.interactionTimestampMsKey), - - attachment.alias[Column.rowID].forKey(Item.attachmentRowIdKey), - interactionAttachment[.albumIndex].forKey(Item.attachmentAlbumIndexKey), - attachment.allColumns() - ) - .aliased(attachment) - .filter(literal: baseQueryFilterSQL) - .joining( - required: Attachment.interactionAttachments - .aliased(interactionAttachment) - .joining( - required: InteractionAttachment.interaction - .aliased(interaction) + fileprivate static func baseQuery(orderSQL: SQL, baseFilterSQL: SQL) -> ((SQL?, SQL?) -> AdaptedFetchRequest>) { + return { additionalFilters, limitSQL -> AdaptedFetchRequest> in + let attachment: TypedTableAlias = TypedTableAlias() + let interaction: TypedTableAlias = TypedTableAlias() + let interactionAttachment: TypedTableAlias = TypedTableAlias() + + let finalFilterSQL: SQL = { + guard let additionalFilters: SQL = additionalFilters else { + return """ + WHERE ( + \(baseFilterSQL) + ) + """ + } + + return """ + WHERE ( + \(baseFilterSQL) AND + \(additionalFilters) ) - ) - .asRequest(of: Item.self) - }() + """ + }() + let finalLimitSQL: SQL = (limitSQL ?? SQL(stringLiteral: "")) + let numColumnsBeforeLinkedRecords: Int = 6 + let request: SQLRequest = """ + SELECT + \(interaction[.id]) AS \(Item.interactionIdKey), + \(interaction[.variant]) AS \(Item.interactionVariantKey), + \(interaction[.authorId]) AS \(Item.interactionAuthorIdKey), + \(interaction[.timestampMs]) AS \(Item.interactionTimestampMsKey), + + \(attachment.alias[Column.rowID]) AS \(Item.rowIdKey), + \(interactionAttachment[.albumIndex]) AS \(Item.attachmentAlbumIndexKey), + \(Item.attachmentKey).* + FROM \(Attachment.self) + \(joinSQL) + \(finalFilterSQL) + ORDER BY \(orderSQL) + \(finalLimitSQL) + """ + + return request.adapted { db in + let adapters = try splittingRowAdapters(columnCounts: [ + numColumnsBeforeLinkedRecords, + Attachment.numberOfSelectedColumns(db) + ]) + + return ScopeAdapter([ + Item.attachmentString: adapters[1] + ]) + } + } + } - fileprivate static let albumQuery: QueryInterfaceRequest = { - let interactionAttachment: TypedTableAlias = TypedTableAlias() - - return Item.baseQuery.order(interactionAttachment[.albumIndex]) - }() - - fileprivate static let galleryQuery: QueryInterfaceRequest = { - return Item.baseQuery - .order(literal: galleryQueryOrderSQL) - }() - - fileprivate static let galleryQueryReversed: QueryInterfaceRequest = { - let interaction: TypedTableAlias = TypedTableAlias() - let interactionAttachment: TypedTableAlias = TypedTableAlias() - - /// **Note:** This **MUST** always result in the same data as `galleryQuery` but in the opposite order - return Item.baseQuery - .order(interaction[.timestampMs], interactionAttachment[.albumIndex].desc) - }() + fileprivate static func baseQuery(orderSQL: SQL, baseFilterSQL: SQL) -> AdaptedFetchRequest> { + return Item.baseQuery(orderSQL: orderSQL, baseFilterSQL: baseFilterSQL)(nil, nil) + } func thumbnailImage(async: @escaping (UIImage) -> ()) { attachment.thumbnail(size: .small, success: { image, _ in async(image) }, failure: {}) @@ -326,345 +310,18 @@ public class MediaGalleryViewModel: TransactionObserver { guard let interactionId: Int64 = interactionId else { return [] } let interaction: TypedTableAlias = TypedTableAlias() + let interactionAttachment: TypedTableAlias = TypedTableAlias() - return try Item.albumQuery - .filter(interaction[.id] == interactionId) + return try Item + .baseQuery( + orderSQL: SQL(interactionAttachment[.albumIndex]), + baseFilterSQL: SQL("\(interaction[.id]) = \(interactionId)") + ) .fetchAll(db) } .removeDuplicates() } - - // MARK: - Gallery - - /// This function is used to load a gallery page using the provided `limitInfo`, if a `focusedAttachmentId` is provided then - /// the `limitInfo.offset` value will be ignored and it will retrieve `limitInfo.limit` values positioning the focussed item - /// as closed to the middle as possible prioritising retrieving `limitInfo.limit` items total - /// - /// **Note:** The `focusedAttachmentId` should only be provided during the first call, subsequent calls should solely provide - /// the `limitInfo` so content can be added before and after the initial page - private func loadGalleryPage( - _ target: PageInfo.Target, - currentPageInfo: PageInfo - ) -> (items: [Item], updatedPageInfo: PageInfo) { - return GRDBStorage.shared - .read { db in - let interaction: TypedTableAlias = TypedTableAlias() - let totalCount: Int = try Item.galleryQuery - .filter(interaction[.threadId] == threadId) - .fetchCount(db) - let queryOffset: Int = { - switch target { - case .before: - return max(0, (currentPageInfo.pageOffset - currentPageInfo.pageSize)) - - case .around(let targetId): - // If we want to focus on a specific item then we need to find it's index in - // the queried data - guard let targetIndex: Int = try? Int.fetchOne(db, Item.galleryIndex(for: targetId)) else { - // If we couldn't find the targetId then just load the page after the current one - return (currentPageInfo.pageOffset + currentPageInfo.pageSize) - } - - // If the focused item is within the first half of the page then we still want - // to retrieve a full page so calculate the offset needed to do so - let halfPageSize: Int = Int(floor(Double(currentPageInfo.pageSize) / 2)) - - // If the focused item is within the first or last half page then just - // start from the start/end of the content - guard targetIndex > halfPageSize else { return 0 } - guard targetIndex < (totalCount - halfPageSize) else { - return (totalCount - currentPageInfo.pageSize) - } - - return (targetIndex - halfPageSize) - - case .after: - return (currentPageInfo.pageOffset + currentPageInfo.currentCount) - } - }() - - let items: [Item] = try Item.galleryQuery - .filter(interaction[.threadId] == threadId) - .limit(currentPageInfo.pageSize, offset: queryOffset) - .fetchAll(db) - let updatedLimitInfo: PageInfo = PageInfo( - pageSize: currentPageInfo.pageSize, - pageOffset: (target != .after ? - queryOffset : - currentPageInfo.pageOffset - ), - currentCount: (currentPageInfo.currentCount + items.count), - totalCount: totalCount - ) - - return (items, updatedLimitInfo) - } - .defaulting(to: ([], currentPageInfo)) - } - - private func addingSystemSections(to data: [SectionModel], for pageInfo: PageInfo) -> [SectionModel] { - // Remove and re-add the custom sections as needed - return [ - (data.isEmpty ? [SectionModel(section: .emptyGallery)] : []), - (!data.isEmpty && pageInfo.pageOffset > 0 ? [SectionModel(section: .loadNewer)] : []), - data.filter { section -> Bool in - switch section.model { - case .galleryMonth: return true - case .emptyGallery, .loadOlder, .loadNewer: return false - } - }, - (!data.isEmpty && (pageInfo.pageOffset + pageInfo.currentCount) < pageInfo.totalCount ? - [SectionModel(section: .loadOlder)] : - [] - ) - ] - .flatMap { $0 } - } - - private func updatedGalleryData( - with existingData: [SectionModel], - dataToUpsert: [Item], - pageInfoToUpdate: PageInfo - ) -> (sections: [SectionModel], pageInfo: PageInfo) { - guard !dataToUpsert.isEmpty else { return (existingData, pageInfoToUpdate) } - - let updatedGalleryData: (sections: [SectionModel], pageInfo: PageInfo) = updatedGalleryData( - with: self.galleryData, - dataToUpsert: (dataToUpsert, pageInfoToUpdate) - ) - let existingDataCount: Int = existingData - .map { $0.elements.count } - .reduce(0, +) - let updatedGalleryDataCount: Int = updatedGalleryData.sections - .map { $0.elements.count } - .reduce(0, +) - let gallerySizeDiff: Int = (updatedGalleryDataCount - existingDataCount) - let updatedPageInfo: PageInfo = PageInfo( - pageSize: pageInfoToUpdate.pageSize, - pageOffset: pageInfoToUpdate.pageOffset, - currentCount: (pageInfoToUpdate.currentCount + gallerySizeDiff), - totalCount: (pageInfoToUpdate.totalCount + gallerySizeDiff) - ) - - // Add the "system" sections, sort the sections and return the result - return ( - self.addingSystemSections(to: updatedGalleryData.sections, for: updatedPageInfo) - .sorted { lhs, rhs -> Bool in (lhs.model > rhs.model) }, - updatedPageInfo - ) - } - - private func updatedGalleryData( - with existingData: [SectionModel], - dataToUpsert: (items: [Item], updatedPageInfo: PageInfo) - ) -> (sections: [SectionModel], pageInfo: PageInfo) { - var updatedGalleryData: [SectionModel] = existingData - - dataToUpsert - .items - .grouped(by: \.galleryDate) - .forEach { key, items in - guard let existingIndex = galleryData.firstIndex(where: { $0.model == .galleryMonth(date: key) }) else { - // Insert a new section - updatedGalleryData.append( - ArraySection( - model: .galleryMonth(date: key), - elements: items - .sorted() - .reversed() - ) - ) - return - } - - // Filter out collisions, replacing them with the updated values and insert - // and new values - let itemRowIds: Set = items.map { $0.attachmentRowId }.asSet() - - updatedGalleryData[existingIndex] = ArraySection( - model: .galleryMonth(date: key), - elements: updatedGalleryData[existingIndex].elements - .filter { !itemRowIds.contains($0.attachmentRowId) } - .appending(contentsOf: items) - .sorted() - .reversed() - ) - } - - // Add the "system" sections, sort the sections and return the result - return ( - self.addingSystemSections(to: updatedGalleryData, for: dataToUpsert.updatedPageInfo) - .sorted { lhs, rhs -> Bool in (lhs.model > rhs.model) }, - dataToUpsert.updatedPageInfo - ) - } - - // MARK: - TransactionObserver - - private struct TrackedChange: Equatable, Hashable { - let kind: DatabaseEvent.Kind - let rowId: Int64 - } - - public func observes(eventsOfKind eventKind: DatabaseEventKind) -> Bool { - switch eventKind { - case .delete(let tableName): return (tableName == Attachment.databaseTableName) - case .update(let tableName, let columnNames): - /// **Warning:** This filtering allows us to ignore all changes to attachments except - /// for the 'isValid' column, unfortunately calling the `with()` function on an attachment - /// does result in this column being seen as updated (even if the value doesn't change) so - /// we need to be careful where we set it to avoid unnecessarily triggering updates - return ( - tableName == Attachment.databaseTableName && - columnNames.contains(Attachment.Columns.isValid.name) - ) - - // We can ignore 'insert' events as we only care about valid attachments - case .insert: return false - } - } - - public func databaseDidChange(with event: DatabaseEvent) { - // This will get called for whenever an Attachment's 'isValid' column is - // updated (ie. an attachment finished uploading/downloading), unfortunately - // we won't know if the attachment is actually relevant yet as it could be for - // another thread or it might not be a media attachment - let trackedChange: TrackedChange = TrackedChange( - kind: event.kind, - rowId: event.rowID - ) - updatedRows.mutate { $0.insert(trackedChange) } - } - - // Note: We will process all updates which come through this method even if - // 'onGalleryChange' is null because if the UI stops observing and then starts again - // later we don't want them to have missed out on changes which happened while they - // weren't subscribed (and doing a full re-query seems painful...) - public func databaseDidCommit(_ db: Database) { - var committedUpdatedRows: Set = [] - self.updatedRows.mutate { updatedRows in - committedUpdatedRows = updatedRows - updatedRows.removeAll() - } - - // Note: This method will be called regardless of whether there were actually changes - // in the areas we are observing so we want to early-out if there aren't any relevant - // updated rows - guard !committedUpdatedRows.isEmpty else { return } - - var updatedPageInfo: PageInfo = self.pageInfo.wrappedValue - let attachmentRowIdsToQuery: Set = committedUpdatedRows - .filter { $0.kind != .delete } - .map { $0.rowId } - .asSet() - let attachmentRowIdsToDelete: Set = committedUpdatedRows - .filter { $0.kind == .delete } - .map { $0.rowId } - .asSet() - let oldGalleryDataCount: Int = self.galleryData - .map { $0.elements.count } - .reduce(0, +) - var galleryDataWithDeletions: [SectionModel] = self.galleryData - - // First remove any items which have been deleted - if !attachmentRowIdsToDelete.isEmpty { - galleryDataWithDeletions = galleryDataWithDeletions - .map { section -> SectionModel in - ArraySection( - model: section.model, - elements: section.elements - .filter { item -> Bool in !attachmentRowIdsToDelete.contains(item.attachmentRowId) } - ) - } - .filter { section -> Bool in !section.elements.isEmpty } - let updatedGalleryDataCount: Int = galleryDataWithDeletions - .map { $0.elements.count } - .reduce(0, +) - - // Make sure there were actually changes - if updatedGalleryDataCount != oldGalleryDataCount { - let gallerySizeDiff: Int = (updatedGalleryDataCount - oldGalleryDataCount) - - updatedPageInfo = PageInfo( - pageSize: updatedPageInfo.pageSize, - pageOffset: updatedPageInfo.pageOffset, - currentCount: (updatedPageInfo.currentCount + gallerySizeDiff), - totalCount: (updatedPageInfo.totalCount + gallerySizeDiff) - ) - } - } - - /// Store the 'deletions-only' update logic in a block as there are a number of places we will fallback to this logic - let sendDeletionsOnlyUpdateIfNeeded: () -> () = { - guard !attachmentRowIdsToDelete.isEmpty else { return } - - DispatchQueue.main.async { [weak self] in - self?.onGalleryChange?(galleryDataWithDeletions, updatedPageInfo) - } - } - - // If there are no inserted/updated rows then trigger the update callback and stop here - guard !attachmentRowIdsToQuery.isEmpty else { - sendDeletionsOnlyUpdateIfNeeded() - return - } - - // Fetch the indexes of the rowIds so we can determine whether they should be added to the screen - let interaction: TypedTableAlias = TypedTableAlias() - let itemIndexes: [Int] = (try? Item.galleryIndexes(for: attachmentRowIdsToQuery, threadId: self.threadId) - .fetchAll(db)) - .defaulting(to: []) - - // Determine if the indexes for the row ids should be displayed on the screen and remove any - // which shouldn't - values less than 'currentCount' or if there is at least one value less than - // 'currentCount' and the indexes are sequential (ie. more than the current loaded content was - // added at once) - let itemsAreSequential: Bool = (itemIndexes.map { $0 - 1 }.dropFirst() == itemIndexes.dropLast()) - let validAttachmentRowIds: Set = (itemsAreSequential && itemIndexes.contains(where: { $0 < updatedPageInfo.currentCount }) ? - attachmentRowIdsToQuery : - zip(itemIndexes, attachmentRowIdsToQuery) - .filter { index, _ -> Bool in index < updatedPageInfo.currentCount } - .map { _, rowId -> Int64 in rowId } - .asSet() - ) - - // If there are no valid attachment row ids then stop here - guard !validAttachmentRowIds.isEmpty else { - sendDeletionsOnlyUpdateIfNeeded() - return - } - - // Fetch the inserted/updated rows - let updatedItems: [Item] = (try? Item.galleryQuery - .filter(validAttachmentRowIds.contains(Column.rowID)) - .filter(interaction[.threadId] == self.threadId) - .fetchAll(db)) - .defaulting(to: []) - - // If the inserted/updated rows we irrelevant (eg. associated to another thread, a quote or a link - // preview) then trigger the update callback (if there were deletions) and stop here - guard !updatedItems.isEmpty else { - sendDeletionsOnlyUpdateIfNeeded() - return - } - - // Process the upserted data - let updatedGalleryData: (sections: [SectionModel], pageInfo: PageInfo) = updatedGalleryData( - with: galleryDataWithDeletions, - dataToUpsert: updatedItems, - pageInfoToUpdate: updatedPageInfo - ) - - DispatchQueue.main.async { [weak self] in - self?.onGalleryChange?(updatedGalleryData.sections, updatedGalleryData.pageInfo) - } - } - - public func databaseDidRollback(_ db: Database) {} - - // MARK: - Functions - @discardableResult public func loadAndCacheAlbumData(for interactionId: Int64) -> [Item] { typealias AlbumInfo = (albumData: [Item], interactionIdBefore: Int64?, interactionIdAfter: Int64?) @@ -673,19 +330,30 @@ public class MediaGalleryViewModel: TransactionObserver { let maybeAlbumInfo: AlbumInfo? = GRDBStorage.shared .read { db -> AlbumInfo in let interaction: TypedTableAlias = TypedTableAlias() - let newAlbumData: [Item] = try Item.albumQuery - .filter(interaction[.id] == interactionId) + let interactionAttachment: TypedTableAlias = TypedTableAlias() + + let newAlbumData: [Item] = try Item + .baseQuery( + orderSQL: SQL(interactionAttachment[.albumIndex]), + baseFilterSQL: SQL("\(interaction[.id]) = \(interactionId)") + ) .fetchAll(db) guard let albumTimestampMs: Int64 = newAlbumData.first?.interactionTimestampMs else { return (newAlbumData, nil, nil) } - let itemBefore: Item? = try Item.galleryQueryReversed - .filter(interaction[.timestampMs] > albumTimestampMs) + let itemBefore: Item? = try Item + .baseQuery( + orderSQL: Item.galleryReverseOrderSQL, + baseFilterSQL: SQL("\(interaction[.timestampMs]) > \(albumTimestampMs)") + ) .fetchOne(db) - let itemAfter: Item? = try Item.galleryQuery - .filter(interaction[.timestampMs] < albumTimestampMs) + let itemAfter: Item? = try Item + .baseQuery( + orderSQL: Item.galleryOrderSQL, + baseFilterSQL: SQL("\(interaction[.timestampMs]) < \(albumTimestampMs)") + ) .fetchOne(db) return (newAlbumData, itemBefore?.interactionId, itemAfter?.interactionId) @@ -709,9 +377,43 @@ public class MediaGalleryViewModel: TransactionObserver { self.albumData[interactionId] = updatedData } - public func updateGalleryData(_ updatedData: [SectionModel], pageInfo: PageInfo) { + // MARK: - Gallery + + private func process(data: [Item], for pageInfo: PagedData.PageInfo) -> [SectionModel] { + let galleryData: [SectionModel] = data + .grouped(by: \.galleryDate) + .mapValues { sectionItems -> [Item] in + sectionItems + .sorted { lhs, rhs -> Bool in + if lhs.interactionTimestampMs == rhs.interactionTimestampMs { + // Start of album first + return (lhs.attachmentAlbumIndex < rhs.attachmentAlbumIndex) + } + + // Newer interactions first + return (lhs.interactionTimestampMs > rhs.interactionTimestampMs) + } + } + .map { galleryDate, items in + SectionModel(model: .galleryMonth(date: galleryDate), elements: items) + } + + // Remove and re-add the custom sections as needed + return [ + (data.isEmpty ? [SectionModel(section: .emptyGallery)] : []), + (!data.isEmpty && pageInfo.pageOffset > 0 ? [SectionModel(section: .loadNewer)] : []), + galleryData, + (!data.isEmpty && (pageInfo.pageOffset + pageInfo.currentCount) < pageInfo.totalCount ? + [SectionModel(section: .loadOlder)] : + [] + ) + ] + .flatMap { $0 } + .sorted { lhs, rhs -> Bool in (lhs.model > rhs.model) } + } + + public func updateGalleryData(_ updatedData: [SectionModel]) { self.galleryData = updatedData - self.pageInfo.mutate { $0 = pageInfo } // If we have a focused attachment id then we need to make sure the 'focusedIndexPath' // is updated to be accurate @@ -732,49 +434,11 @@ public class MediaGalleryViewModel: TransactionObserver { } public func loadNewerGalleryItems() { - // Only allow on 'load older' fetch at a time - guard !isFetchingMoreItems.wrappedValue else { return } - - // Prevent more fetching until we have completed adding the page - isFetchingMoreItems.mutate { $0 = true } - - // Load the page before the current data (newer items) then merge and sort - // with the current data - let updatedGalleryData: (sections: [SectionModel], pageInfo: PageInfo) = updatedGalleryData( - with: galleryData, - dataToUpsert: loadGalleryPage( - .before, - currentPageInfo: pageInfo.wrappedValue - ) - ) - - DispatchQueue.main.async { [weak self] in - self?.onGalleryChange?(updatedGalleryData.sections, updatedGalleryData.pageInfo) - self?.isFetchingMoreItems.mutate { $0 = false } - } + self.pagedDatabaseObserver?.load(.pageBefore) } public func loadOlderGalleryItems() { - // Only allow on 'load older' fetch at a time - guard !isFetchingMoreItems.wrappedValue else { return } - - // Prevent more fetching until we have completed adding the page - isFetchingMoreItems.mutate { $0 = true } - - // Load the page after the current data (older items) then merge and sort - // with the current data - let updatedGalleryData: (sections: [SectionModel], pageInfo: PageInfo) = updatedGalleryData( - with: galleryData, - dataToUpsert: loadGalleryPage( - .after, - currentPageInfo: pageInfo.wrappedValue - ) - ) - - DispatchQueue.main.async { [weak self] in - self?.onGalleryChange?(updatedGalleryData.sections, updatedGalleryData.pageInfo) - self?.isFetchingMoreItems.mutate { $0 = false } - } + self.pagedDatabaseObserver?.load(.pageAfter) } public func updateFocusedItem(attachmentId: String, indexPath: IndexPath) { @@ -798,7 +462,8 @@ public class MediaGalleryViewModel: TransactionObserver { // transitions work nicely) let viewModel: MediaGalleryViewModel = MediaGalleryViewModel( threadId: threadId, - threadVariant: threadVariant + threadVariant: threadVariant, + isPagedData: false ) viewModel.loadAndCacheAlbumData(for: interactionId) viewModel.replaceAlbumObservation(toObservationFor: interactionId) @@ -831,32 +496,11 @@ public class MediaGalleryViewModel: TransactionObserver { let viewModel: MediaGalleryViewModel = MediaGalleryViewModel( threadId: threadId, threadVariant: threadVariant, + isPagedData: true, pageSize: MediaTileViewController.itemPageSize, focusedAttachmentId: focusedAttachmentId ) - // Load the data for the album immediately (needed before pushing to the screen so - // transitions work nicely) - let pageTarget: PageInfo.Target = { - // If we don't have a `focusedAttachmentId` then default to `.before` (it'll query - // from a `0` offset - guard let targetId: String = focusedAttachmentId else { return .before } - - return .around(id: targetId) - }() - let initialGalleryData: (sections: [SectionModel], pageInfo: PageInfo) = viewModel.updatedGalleryData( - with: [], - dataToUpsert: viewModel.loadGalleryPage( - pageTarget, - currentPageInfo: PageInfo(pageSize: MediaTileViewController.itemPageSize) - ) - ) - - viewModel.updateGalleryData( - initialGalleryData.sections, - pageInfo: initialGalleryData.pageInfo - ) - return MediaTileViewController( viewModel: viewModel ) diff --git a/Session/Media Viewing & Editing/MediaTileViewController.swift b/Session/Media Viewing & Editing/MediaTileViewController.swift index 049938c62..8f2aac326 100644 --- a/Session/Media Viewing & Editing/MediaTileViewController.swift +++ b/Session/Media Viewing & Editing/MediaTileViewController.swift @@ -34,9 +34,7 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour init(viewModel: MediaGalleryViewModel) { self.viewModel = viewModel - - // Start observing database changes - GRDBStorage.shared.addObserver(viewModel) + GRDBStorage.shared.addObserver(viewModel.pagedDatabaseObserver) super.init(nibName: nil, bundle: nil) } @@ -163,8 +161,7 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour public override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) - // Stop observing database changes - self.viewModel.onGalleryChange = nil + stopObservingChanges() } @objc func applicationDidBecomeActive(_ notification: Notification) { @@ -172,8 +169,7 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour } @objc func applicationDidResignActive(_ notification: Notification) { - // Stop observing database changes - self.viewModel.onGalleryChange = nil + stopObservingChanges() } override public func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { @@ -240,17 +236,23 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour private func startObservingChanges() { // Start observing for data changes (will callback on the main thread) - self.viewModel.onGalleryChange = { [weak self] updatedGalleryData, pageInfo in - self?.handleUpdates(updatedGalleryData, pageInfo: pageInfo) + self.viewModel.onGalleryChange = { [weak self] updatedGalleryData in + self?.handleUpdates(updatedGalleryData) } } - private func handleUpdates(_ updatedGalleryData: [MediaGalleryViewModel.SectionModel], pageInfo: MediaGalleryViewModel.PageInfo) { + private func stopObservingChanges() { + // Note: The 'PagedDatabaseObserver' will continue to get changes but + // we don't want to trigger any UI updates + self.viewModel.onGalleryChange = nil + } + + private func handleUpdates(_ updatedGalleryData: [MediaGalleryViewModel.SectionModel]) { // Ensure the first load runs without animations (if we don't do this the cells will animate // in from a frame of CGRect.zero) guard hasLoadedInitialData else { UIView.performWithoutAnimation { - handleUpdates(updatedGalleryData, pageInfo: pageInfo) + handleUpdates(updatedGalleryData) triggerInitialDataLoadIfNeeded() } return @@ -291,7 +293,7 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour using: StagedChangeset(source: self.viewModel.galleryData, target: updatedGalleryData), interrupt: { $0.changeCount > MediaTileViewController.itemPageSize } ) { [weak self] updatedData in - self?.viewModel.updateGalleryData(updatedData, pageInfo: pageInfo) + self?.viewModel.updateGalleryData(updatedData) } CATransaction.setCompletionBlock { [weak self] in diff --git a/SessionMessagingKit/Database/Models/Interaction.swift b/SessionMessagingKit/Database/Models/Interaction.swift index e37a43bcf..bc503dda3 100644 --- a/SessionMessagingKit/Database/Models/Interaction.swift +++ b/SessionMessagingKit/Database/Models/Interaction.swift @@ -36,7 +36,7 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu public static let recipientStates = hasMany(RecipientState.self, using: RecipientState.interactionForeignKey) public typealias Columns = CodingKeys - public enum CodingKeys: String, CodingKey, ColumnExpression { + public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { case id case serverHash case threadId @@ -60,7 +60,7 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu case openGroupWhisperTo } - public enum Variant: Int, Codable, DatabaseValueConvertible { + public enum Variant: Int, Codable, Hashable, DatabaseValueConvertible { case standardIncoming case standardOutgoing case standardIncomingDeleted diff --git a/SessionMessagingKit/Database/Models/InteractionAttachment.swift b/SessionMessagingKit/Database/Models/InteractionAttachment.swift index 879ac0f45..465c124b2 100644 --- a/SessionMessagingKit/Database/Models/InteractionAttachment.swift +++ b/SessionMessagingKit/Database/Models/InteractionAttachment.swift @@ -4,7 +4,7 @@ import Foundation import GRDB import SessionUtilitiesKit -public struct InteractionAttachment: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { +public struct InteractionAttachment: Codable, Equatable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "interactionAttachment" } internal static let interactionForeignKey = ForeignKey([Columns.interactionId], to: [Interaction.Columns.id]) internal static let attachmentForeignKey = ForeignKey([Columns.attachmentId], to: [Attachment.Columns.id]) diff --git a/SessionMessagingKit/Database/Models/LinkPreview.swift b/SessionMessagingKit/Database/Models/LinkPreview.swift index ca6a2d439..9fe7eaa0e 100644 --- a/SessionMessagingKit/Database/Models/LinkPreview.swift +++ b/SessionMessagingKit/Database/Models/LinkPreview.swift @@ -7,7 +7,7 @@ import AFNetworking import SignalCoreKit import SessionUtilitiesKit -public struct LinkPreview: Codable, Equatable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { +public struct LinkPreview: Codable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "linkPreview" } internal static let interactionForeignKey = ForeignKey( [Columns.url], @@ -28,7 +28,7 @@ public struct LinkPreview: Codable, Equatable, FetchableRecord, PersistableRecor case attachmentId } - public enum Variant: Int, Codable, DatabaseValueConvertible { + public enum Variant: Int, Codable, Hashable, DatabaseValueConvertible { case standard case openGroupInvitation } diff --git a/SessionMessagingKit/Database/Models/Profile.swift b/SessionMessagingKit/Database/Models/Profile.swift index 1967ca1bc..4c3178c74 100644 --- a/SessionMessagingKit/Database/Models/Profile.swift +++ b/SessionMessagingKit/Database/Models/Profile.swift @@ -345,8 +345,8 @@ public extension Profile { } /// A standardised mechanism for truncating a user id for a given thread - static func truncated(id: String, thread: SessionThread) -> String { - switch thread.variant { + static func truncated(id: String, threadVariant: SessionThread.Variant = .contact) -> String { + switch threadVariant { case .openGroup: return truncated(id: id, truncating: .start) default: return truncated(id: id, truncating: .middle) } @@ -378,7 +378,7 @@ public extension Profile { if let nickname: String = nickname { return nickname } guard let name: String = name, name != id else { - return (customFallback ?? Profile.truncated(id: id, truncating: .middle)) + return (customFallback ?? Profile.truncated(id: id, threadVariant: threadVariant)) } switch threadVariant { diff --git a/SessionMessagingKit/Database/Models/Quote.swift b/SessionMessagingKit/Database/Models/Quote.swift index 9da8cd81c..5a867f1de 100644 --- a/SessionMessagingKit/Database/Models/Quote.swift +++ b/SessionMessagingKit/Database/Models/Quote.swift @@ -4,7 +4,7 @@ import Foundation import GRDB import SessionUtilitiesKit -public struct Quote: Codable, Equatable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { +public struct Quote: Codable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "quote" } public static let interactionForeignKey = ForeignKey([Columns.interactionId], to: [Interaction.Columns.id]) internal static let originalInteractionForeignKey = ForeignKey( diff --git a/SessionMessagingKit/Database/Models/RecipientState.swift b/SessionMessagingKit/Database/Models/RecipientState.swift index e96da4c71..e56c3f05d 100644 --- a/SessionMessagingKit/Database/Models/RecipientState.swift +++ b/SessionMessagingKit/Database/Models/RecipientState.swift @@ -21,7 +21,7 @@ public struct RecipientState: Codable, Equatable, FetchableRecord, PersistableRe case mostRecentFailureText } - public enum State: Int, Codable, DatabaseValueConvertible { + public enum State: Int, Codable, Hashable, DatabaseValueConvertible { case failed case sending case skipped @@ -117,3 +117,30 @@ public extension RecipientState { ) } } + +// MARK: - GRDB Queries + +public extension RecipientState { + static func selectInteractionState(tableLiteral: SQL, idColumnLiteral: SQL) -> SQL { + let interaction: TypedTableAlias = TypedTableAlias() + let recipientState: TypedTableAlias = TypedTableAlias() + + return """ + SELECT * FROM ( + SELECT + \(recipientState[.interactionId]), + \(recipientState[.state]), + \(recipientState[.mostRecentFailureText]) + FROM \(RecipientState.self) + JOIN \(Interaction.self) ON \(interaction[.id]) = \(recipientState[.interactionId]) + WHERE \(SQL("\(recipientState[.state]) != \(RecipientState.State.skipped)")) -- Ignore 'skipped' + ORDER BY + -- If there is a single 'sending' then should be 'sending', otherwise if there is a single + -- 'failed' and there is no 'sending' then it should be 'failed' + \(SQL("\(recipientState[.state]) = \(RecipientState.State.sending)")) DESC, + \(SQL("\(recipientState[.state]) = \(RecipientState.State.failed)")) DESC + ) AS \(tableLiteral) + GROUP BY \(tableLiteral).\(idColumnLiteral) + """ + } +} diff --git a/SessionMessagingKit/Database/Models/SessionThread.swift b/SessionMessagingKit/Database/Models/SessionThread.swift index 2fd51a151..f181f3a5a 100644 --- a/SessionMessagingKit/Database/Models/SessionThread.swift +++ b/SessionMessagingKit/Database/Models/SessionThread.swift @@ -32,7 +32,7 @@ public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord, case onlyNotifyForMentions } - public enum Variant: Int, Codable, DatabaseValueConvertible { + public enum Variant: Int, Codable, Hashable, DatabaseValueConvertible { case contact case closedGroup case openGroup diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift index 6e092a15b..b58b7614d 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift @@ -105,13 +105,15 @@ extension MessageReceiver { case .started: TypingIndicators.didStartTyping( db, - in: thread, + threadId: thread.id, + threadVariant: thread.variant, + threadIsMessageRequest: thread.isMessageRequest(db), direction: .incoming, timestampMs: message.sentTimestamp.map { Int64($0) } ) case .stopped: - TypingIndicators.didStopTyping(db, in: thread, direction: .incoming) + TypingIndicators.didStopTyping(db, threadId: thread.id, direction: .incoming) default: SNLog("Unknown TypingIndicator Kind ignored") @@ -582,7 +584,7 @@ extension MessageReceiver { // Cancel any typing indicators if needed if isMainAppActive { - TypingIndicators.didStopTyping(db, in: thread, direction: .incoming) + TypingIndicators.didStopTyping(db, threadId: thread.id, direction: .incoming) } // Update the contact's approval status of the current user if needed (if we are getting messages from diff --git a/SessionMessagingKit/Sending & Receiving/Quotes/QuotedReplyModel.swift b/SessionMessagingKit/Sending & Receiving/Quotes/QuotedReplyModel.swift index 07cb06126..abde118f3 100644 --- a/SessionMessagingKit/Sending & Receiving/Quotes/QuotedReplyModel.swift +++ b/SessionMessagingKit/Sending & Receiving/Quotes/QuotedReplyModel.swift @@ -42,19 +42,21 @@ public struct QuotedReplyModel { body: String?, timestampMs: Int64, attachments: [Attachment]?, - linkPreview: LinkPreview? + linkPreviewAttachment: Attachment? ) -> QuotedReplyModel? { guard variant == .standardOutgoing || variant == .standardIncoming else { return nil } guard (body != nil && body?.isEmpty == false) || attachments?.isEmpty == false else { return nil } + let targetAttachment: Attachment? = (attachments?.first ?? linkPreviewAttachment) + return QuotedReplyModel( threadId: threadId, authorId: authorId, timestampMs: timestampMs, body: body, - attachment: attachments?.first, - contentType: attachments?.first?.contentType, - sourceFileName: attachments?.first?.sourceFilename, + attachment: targetAttachment, + contentType: targetAttachment?.contentType, + sourceFileName: targetAttachment?.sourceFilename, thumbnailDownloadFailed: false ) } diff --git a/SessionMessagingKit/Sending & Receiving/Typing Indicators/TypingIndicators.swift b/SessionMessagingKit/Sending & Receiving/Typing Indicators/TypingIndicators.swift index 5c2d83611..71ebf2553 100644 --- a/SessionMessagingKit/Sending & Receiving/Typing Indicators/TypingIndicators.swift +++ b/SessionMessagingKit/Sending & Receiving/Typing Indicators/TypingIndicators.swift @@ -13,38 +13,38 @@ public class TypingIndicators { } private class Indicator { - fileprivate let thread: SessionThread + fileprivate let threadId: String fileprivate let direction: Direction fileprivate let timestampMs: Int64 fileprivate var refreshTimer: Timer? fileprivate var stopTimer: Timer? - init?(thread: SessionThread, direction: Direction, timestampMs: Int64?) { + init?( + threadId: String, + threadVariant: SessionThread.Variant, + threadIsMessageRequest: Bool, + direction: Direction, + timestampMs: Int64? + ) { // The `typingIndicatorsEnabled` flag reflects the user-facing setting in the app // preferences, if it's disabled we don't want to emit "typing indicator" messages // or show typing indicators for other users // // We also don't want to show/send typing indicators for message requests - guard GRDBStorage.shared.read({ db in - ( - db[.typingIndicatorsEnabled] == true && - thread.isMessageRequest(db) == false - ) - }) == true else { + guard GRDBStorage.shared[.typingIndicatorsEnabled] && !threadIsMessageRequest else { return nil } // Don't send typing indicators in group threads - guard thread.variant != .closedGroup && thread.variant != .openGroup else { return nil } + guard threadVariant != .closedGroup && threadVariant != .openGroup else { return nil } - self.thread = thread + self.threadId = threadId self.direction = direction self.timestampMs = (timestampMs ?? Int64(floor(Date().timeIntervalSince1970 * 1000))) } fileprivate func starting(_ db: Database) -> Indicator { - let thread: SessionThread = self.thread let direction: Direction = self.direction let timestampMs: Int64 = self.timestampMs @@ -55,7 +55,7 @@ public class TypingIndicators { case .incoming: try? ThreadTypingIndicator( - threadId: thread.id, + threadId: self.threadId, timestampMs: timestampMs ) .save(db) @@ -83,6 +83,10 @@ public class TypingIndicators { switch direction { case .outgoing: + guard let thread: SessionThread = try? SessionThread.fetchOne(db, id: self.threadId) else { + return nil + } + try? MessageSender.send( db, message: TypingIndicator(kind: .stopped), @@ -92,7 +96,7 @@ public class TypingIndicators { case .incoming: _ = try? ThreadTypingIndicator - .filter(ThreadTypingIndicator.Columns.threadId == thread.id) + .filter(ThreadTypingIndicator.Columns.threadId == self.threadId) .deleteAll(db) } @@ -101,6 +105,10 @@ public class TypingIndicators { private func scheduleRefreshCallback(_ db: Database, shouldSend: Bool = true) { if shouldSend { + guard let thread: SessionThread = try? SessionThread.fetchOne(db, id: self.threadId) else { + return + } + try? MessageSender.send( db, message: TypingIndicator(kind: .started), @@ -130,37 +138,56 @@ public class TypingIndicators { // MARK: - Functions - public static func didStartTyping(_ db: Database, in thread: SessionThread, direction: Direction, timestampMs: Int64?) { + public static func didStartTyping( + _ db: Database, + threadId: String, + threadVariant: SessionThread.Variant, + threadIsMessageRequest: Bool, + direction: Direction, + timestampMs: Int64? + ) { switch direction { case .outgoing: let updatedIndicator: Indicator? = ( - outgoing.wrappedValue[thread.id] ?? - Indicator(thread: thread, direction: direction, timestampMs: timestampMs) + outgoing.wrappedValue[threadId] ?? + Indicator( + threadId: threadId, + threadVariant: threadVariant, + threadIsMessageRequest: threadIsMessageRequest, + direction: direction, + timestampMs: timestampMs + ) )?.starting(db) - outgoing.mutate { $0[thread.id] = updatedIndicator } + outgoing.mutate { $0[threadId] = updatedIndicator } case .incoming: let updatedIndicator: Indicator? = ( - incoming.wrappedValue[thread.id] ?? - Indicator(thread: thread, direction: direction, timestampMs: timestampMs) + incoming.wrappedValue[threadId] ?? + Indicator( + threadId: threadId, + threadVariant: threadVariant, + threadIsMessageRequest: threadIsMessageRequest, + direction: direction, + timestampMs: timestampMs + ) )?.starting(db) - incoming.mutate { $0[thread.id] = updatedIndicator } + incoming.mutate { $0[threadId] = updatedIndicator } } } - public static func didStopTyping(_ db: Database, in thread: SessionThread, direction: Direction) { + public static func didStopTyping(_ db: Database, threadId: String, direction: Direction) { switch direction { case .outgoing: - let updatedIndicator: Indicator? = outgoing.wrappedValue[thread.id]?.stoping(db) + let updatedIndicator: Indicator? = outgoing.wrappedValue[threadId]?.stoping(db) - outgoing.mutate { $0[thread.id] = updatedIndicator } + outgoing.mutate { $0[threadId] = updatedIndicator } case .incoming: - let updatedIndicator: Indicator? = incoming.wrappedValue[thread.id]?.stoping(db) + let updatedIndicator: Indicator? = incoming.wrappedValue[threadId]?.stoping(db) - incoming.mutate { $0[thread.id] = updatedIndicator } + incoming.mutate { $0[threadId] = updatedIndicator } } } } diff --git a/SessionMessagingKit/Shared Models/ConversationCellViewModel.swift b/SessionMessagingKit/Shared Models/ConversationCellViewModel.swift index 4126a2ffb..7d9b6d93a 100644 --- a/SessionMessagingKit/Shared Models/ConversationCellViewModel.swift +++ b/SessionMessagingKit/Shared Models/ConversationCellViewModel.swift @@ -23,21 +23,31 @@ extension ConversationCell { public static let threadCreationDateTimestampKey: SQL = SQL(stringLiteral: CodingKeys.threadCreationDateTimestamp.stringValue) public static let threadMemberNamesKey: SQL = SQL(stringLiteral: CodingKeys.threadMemberNames.stringValue) public static let threadIsNoteToSelfKey: SQL = SQL(stringLiteral: CodingKeys.threadIsNoteToSelf.stringValue) + public static let threadIsMessageRequestKey: SQL = SQL(stringLiteral: CodingKeys.threadIsMessageRequest.stringValue) + public static let threadRequiresApprovalKey: SQL = SQL(stringLiteral: CodingKeys.threadRequiresApproval.stringValue) + public static let threadShouldBeVisibleKey: SQL = SQL(stringLiteral: CodingKeys.threadShouldBeVisible.stringValue) public static let threadIsPinnedKey: SQL = SQL(stringLiteral: CodingKeys.threadIsPinned.stringValue) public static let threadIsBlockedKey: SQL = SQL(stringLiteral: CodingKeys.threadIsBlocked.stringValue) public static let threadMutedUntilTimestampKey: SQL = SQL(stringLiteral: CodingKeys.threadMutedUntilTimestamp.stringValue) public static let threadOnlyNotifyForMentionsKey: SQL = SQL(stringLiteral: CodingKeys.threadOnlyNotifyForMentions.stringValue) + public static let threadMessageDraftKey: SQL = SQL(stringLiteral: CodingKeys.threadMessageDraft.stringValue) public static let threadContactIsTypingKey: SQL = SQL(stringLiteral: CodingKeys.threadContactIsTyping.stringValue) public static let threadUnreadCountKey: SQL = SQL(stringLiteral: CodingKeys.threadUnreadCount.stringValue) public static let threadUnreadMentionCountKey: SQL = SQL(stringLiteral: CodingKeys.threadUnreadMentionCount.stringValue) + public static let threadFirstUnreadInteractionIdKey: SQL = SQL(stringLiteral: CodingKeys.threadFirstUnreadInteractionId.stringValue) public static let contactProfileKey: SQL = SQL(stringLiteral: CodingKeys.contactProfile.stringValue) public static let closedGroupNameKey: SQL = SQL(stringLiteral: CodingKeys.closedGroupName.stringValue) + public static let closedGroupUserCountKey: SQL = SQL(stringLiteral: CodingKeys.closedGroupUserCount.stringValue) + public static let currentUserIsClosedGroupMemberKey: SQL = SQL(stringLiteral: CodingKeys.currentUserIsClosedGroupMember.stringValue) public static let currentUserIsClosedGroupAdminKey: SQL = SQL(stringLiteral: CodingKeys.currentUserIsClosedGroupAdmin.stringValue) public static let closedGroupProfileFrontKey: SQL = SQL(stringLiteral: CodingKeys.closedGroupProfileFront.stringValue) public static let closedGroupProfileBackKey: SQL = SQL(stringLiteral: CodingKeys.closedGroupProfileBack.stringValue) public static let closedGroupProfileBackFallbackKey: SQL = SQL(stringLiteral: CodingKeys.closedGroupProfileBackFallback.stringValue) public static let openGroupNameKey: SQL = SQL(stringLiteral: CodingKeys.openGroupName.stringValue) + public static let openGroupServerKey: SQL = SQL(stringLiteral: CodingKeys.openGroupServer.stringValue) + public static let openGroupRoomKey: SQL = SQL(stringLiteral: CodingKeys.openGroupRoom.stringValue) public static let openGroupProfilePictureDataKey: SQL = SQL(stringLiteral: CodingKeys.openGroupProfilePictureData.stringValue) + public static let openGroupUserCountKey: SQL = SQL(stringLiteral: CodingKeys.openGroupUserCount.stringValue) public static let interactionIdKey: SQL = SQL(stringLiteral: CodingKeys.interactionId.stringValue) public static let interactionVariantKey: SQL = SQL(stringLiteral: CodingKeys.interactionVariant.stringValue) public static let interactionTimestampMsKey: SQL = SQL(stringLiteral: CodingKeys.interactionTimestampMs.stringValue) @@ -50,6 +60,9 @@ extension ConversationCell { public static let threadUnreadCountString: String = CodingKeys.threadUnreadCount.stringValue public static let threadUnreadMentionCountString: String = CodingKeys.threadUnreadMentionCount.stringValue + public static let threadFirstUnreadInteractionIdString: String = CodingKeys.threadFirstUnreadInteractionId.stringValue + public static let closedGroupUserCountString: String = CodingKeys.closedGroupUserCount.stringValue + public static let openGroupUserCountString: String = CodingKeys.openGroupUserCount.stringValue public static let contactProfileString: String = CodingKeys.contactProfile.stringValue public static let closedGroupProfileFrontString: String = CodingKeys.closedGroupProfileFront.stringValue public static let closedGroupProfileBackString: String = CodingKeys.closedGroupProfileBack.stringValue @@ -64,14 +77,19 @@ extension ConversationCell { public let threadMemberNames: String? public let threadIsNoteToSelf: Bool + public var threadIsMessageRequest: Bool? + public let threadRequiresApproval: Bool? + public let threadShouldBeVisible: Bool? public let threadIsPinned: Bool public var threadIsBlocked: Bool? public let threadMutedUntilTimestamp: TimeInterval? public let threadOnlyNotifyForMentions: Bool? + public let threadMessageDraft: String? public let threadContactIsTyping: Bool? public let threadUnreadCount: UInt? public let threadUnreadMentionCount: UInt? + public let threadFirstUnreadInteractionId: Int64? // Thread display info @@ -80,9 +98,14 @@ extension ConversationCell { private let closedGroupProfileBack: Profile? private let closedGroupProfileBackFallback: Profile? public let closedGroupName: String? + private let closedGroupUserCount: Int? + public let currentUserIsClosedGroupMember: Bool? public let currentUserIsClosedGroupAdmin: Bool? public let openGroupName: String? + public let openGroupServer: String? + public let openGroupRoom: String? public let openGroupProfilePictureData: Data? + private let openGroupUserCount: Int? // Interaction display info @@ -135,6 +158,23 @@ extension ConversationCell { return Date(timeIntervalSince1970: (TimeInterval(interactionTimestampMs) / 1000)) } + public var enabledMessageTypes: MessageInputTypes { + guard !threadIsNoteToSelf else { return .all } + + return (threadRequiresApproval == false && threadIsMessageRequest == false ? + .all : + .textOnly + ) + } + + public var userCount: Int? { + switch threadVariant { + case .contact: return nil + case .closedGroup: return closedGroupUserCount + case .openGroup: return openGroupUserCount + } + } + /// This function returns the profile name formatted for the specific type of thread provided /// /// **Note:** The 'threadVariant' parameter is used for profile context but in the search results we actually want this @@ -166,14 +206,19 @@ public extension ConversationCell.ViewModel { self.threadMemberNames = nil self.threadIsNoteToSelf = false + self.threadIsMessageRequest = false + self.threadRequiresApproval = false + self.threadShouldBeVisible = false self.threadIsPinned = false self.threadIsBlocked = nil self.threadMutedUntilTimestamp = nil self.threadOnlyNotifyForMentions = nil + self.threadMessageDraft = nil self.threadContactIsTyping = nil self.threadUnreadCount = unreadCount self.threadUnreadMentionCount = nil + self.threadFirstUnreadInteractionId = nil // Thread display info @@ -182,9 +227,14 @@ public extension ConversationCell.ViewModel { self.closedGroupProfileBack = nil self.closedGroupProfileBackFallback = nil self.closedGroupName = nil + self.closedGroupUserCount = nil + self.currentUserIsClosedGroupMember = nil self.currentUserIsClosedGroupAdmin = nil self.openGroupName = nil + self.openGroupServer = nil + self.openGroupRoom = nil self.openGroupProfilePictureData = nil + self.openGroupUserCount = nil // Interaction display info @@ -221,7 +271,6 @@ public extension ConversationCell.ViewModel { let linkPreview: TypedTableAlias = TypedTableAlias() let interactionAttachment: TypedTableAlias = TypedTableAlias() let attachment: TypedTableAlias = TypedTableAlias() - let recipientState: TypedTableAlias = TypedTableAlias() let unreadCountTableLiteral: SQL = SQL(stringLiteral: "\(ViewModel.threadUnreadCountString)_table") let unreadMentionCountTableLiteral: SQL = SQL(stringLiteral: "\(ViewModel.threadUnreadMentionCountString)_table") @@ -240,7 +289,7 @@ public extension ConversationCell.ViewModel { let interactionStateStateColumnLiteral: SQL = SQL(stringLiteral: RecipientState.Columns.state.name) /// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before - /// the `ViewModel.closedGroupProfileFrontKey` entry below otherwise the query will fail to + /// the `ViewModel.contactProfileKey` entry below otherwise the query will fail to /// parse and might throw /// /// Explicitly set default values for the fields ignored for search results @@ -372,20 +421,10 @@ public extension ConversationCell.ViewModel { FROM \(Attachment.self) ) AS \(ViewModel.interactionAttachmentDescriptionInfoKey) ON \(ViewModel.interactionAttachmentDescriptionInfoKey).\(attachmentIdColumnLiteral) = \(firstInteractionAttachmentLiteral).\(interactionAttachmentAttachmentIdColumnLiteral) LEFT JOIN ( - SELECT * FROM ( - SELECT - \(recipientState[.interactionId]), - \(recipientState[.state]) - FROM \(RecipientState.self) - JOIN \(Interaction.self) ON \(interaction[.id]) = \(recipientState[.interactionId]) - WHERE \(SQL("\(recipientState[.state]) != \(RecipientState.State.skipped)")) -- Ignore 'skipped' - ORDER BY - -- If there is a single 'sending' then should be 'sending', otherwise if there is a single - -- 'failed' and there is no 'sending' then it should be 'failed' - \(SQL("\(recipientState[.state]) = \(RecipientState.State.sending)")) DESC, - \(SQL("\(recipientState[.state]) = \(RecipientState.State.failed)")) DESC - ) AS \(interactionStateTableLiteral) - GROUP BY \(interactionStateTableLiteral).\(interactionStateInteractionIdColumnLiteral) + \(RecipientState.selectInteractionState( + tableLiteral: interactionStateTableLiteral, + idColumnLiteral: interactionStateInteractionIdColumnLiteral + )) ) AS \(interactionStateTableLiteral) ON \(interactionStateTableLiteral).\(interactionStateInteractionIdColumnLiteral) = \(interaction[.id]) WHERE ( @@ -467,6 +506,153 @@ public extension ConversationCell.ViewModel { } } +// MARK: - ConversationVC + +public extension ConversationCell.ViewModel { + static func conversationQuery(threadId: String, userPublicKey: String) -> AdaptedFetchRequest> { + let thread: TypedTableAlias = TypedTableAlias() + let contact: TypedTableAlias = TypedTableAlias() + let typingIndicator: TypedTableAlias = TypedTableAlias() + let closedGroup: TypedTableAlias = TypedTableAlias() + let groupMember: TypedTableAlias = TypedTableAlias() + let openGroup: TypedTableAlias = TypedTableAlias() + let interaction: TypedTableAlias = TypedTableAlias() + + let unreadCountTableLiteral: SQL = SQL(stringLiteral: "\(ViewModel.threadUnreadCountString)_table") + let unreadMentionCountTableLiteral: SQL = SQL(stringLiteral: "\(ViewModel.threadUnreadMentionCountString)_table") + let firstUnreadInteractionTableLiteral: SQL = SQL(stringLiteral: "\(ViewModel.threadFirstUnreadInteractionIdString)_table") + let interactionIdLiteral: SQL = SQL(stringLiteral: Interaction.Columns.id.name) + let interactionThreadIdLiteral: SQL = SQL(stringLiteral: Interaction.Columns.threadId.name) + let closedGroupUserCountTableLiteral: SQL = SQL(stringLiteral: "\(ViewModel.closedGroupUserCountString)_table") + let groupMemberGroupIdColumnLiteral: SQL = SQL(stringLiteral: GroupMember.Columns.groupId.name) + let profileIdColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.id.name) + + /// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before + /// the `ViewModel.contactProfileKey` entry below otherwise the query will fail to + /// parse and might throw + /// + /// Explicitly set default values for the fields ignored for search results + let numColumnsBeforeProfiles: Int = 16 + let request: SQLRequest = """ + SELECT + \(thread[.id]) AS \(ViewModel.threadIdKey), + \(thread[.variant]) AS \(ViewModel.threadVariantKey), + \(thread[.creationDateTimestamp]) AS \(ViewModel.threadCreationDateTimestampKey), + + (\(SQL("\(thread[.id]) = \(userPublicKey)"))) AS \(ViewModel.threadIsNoteToSelfKey), + ( + \(thread[.shouldBeVisible]) = true AND + \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND + \(SQL("\(thread[.id]) != \(userPublicKey)")) AND ( + -- A '!= true' check doesn't work properly so we need to be explicit + \(contact[.isApproved]) IS NULL OR + \(contact[.isApproved]) = false + ) + ) AS \(ViewModel.threadIsMessageRequestKey), + ( + IFNULL(\(contact[.isApproved]), false) = false OR + IFNULL(\(contact[.didApproveMe]), false) = false + ) AS \(ViewModel.threadRequiresApprovalKey), + \(thread[.shouldBeVisible]) AS \(ViewModel.threadShouldBeVisibleKey), + + \(thread[.isPinned]) AS \(ViewModel.threadIsPinnedKey), + \(contact[.isBlocked]) AS \(ViewModel.threadIsBlockedKey), + \(thread[.mutedUntilTimestamp]) AS \(ViewModel.threadMutedUntilTimestampKey), + \(thread[.onlyNotifyForMentions]) AS \(ViewModel.threadOnlyNotifyForMentionsKey), + \(thread[.messageDraft]) AS \(ViewModel.threadMessageDraftKey), + + (\(typingIndicator[.threadId]) IS NOT NULL) AS \(ViewModel.threadContactIsTypingKey), + \(unreadCountTableLiteral).\(ViewModel.threadUnreadCountKey) AS \(ViewModel.threadUnreadCountKey), + \(unreadMentionCountTableLiteral).\(ViewModel.threadUnreadMentionCountKey) AS \(ViewModel.threadUnreadMentionCountKey), + \(firstUnreadInteractionTableLiteral).\(interactionIdLiteral) AS \(ViewModel.threadFirstUnreadInteractionIdKey), + + \(ViewModel.contactProfileKey).*, + \(closedGroup[.name]) AS \(ViewModel.closedGroupNameKey), + \(closedGroupUserCountTableLiteral).\(ViewModel.closedGroupUserCountKey) AS \(ViewModel.closedGroupUserCountKey), + (\(groupMember[.profileId]) IS NOT NULL) AS \(ViewModel.currentUserIsClosedGroupMemberKey), + \(openGroup[.name]) AS \(ViewModel.openGroupNameKey), + \(openGroup[.server]) AS \(ViewModel.openGroupServerKey), + \(openGroup[.room]) AS \(ViewModel.openGroupRoomKey), + \(openGroup[.imageData]) AS \(ViewModel.openGroupProfilePictureDataKey), + \(openGroup[.userCount]) AS \(ViewModel.openGroupUserCountKey), + + \(SQL("\(userPublicKey)")) AS \(ViewModel.currentUserPublicKeyKey) + + FROM \(SessionThread.self) + LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id]) + LEFT JOIN \(ThreadTypingIndicator.self) ON \(typingIndicator[.threadId]) = \(thread[.id]) + LEFT JOIN ( + SELECT + \(interaction[.id]), + \(interaction[.threadId]), + MIN(\(interaction[.timestampMs])) + FROM \(Interaction.self) + WHERE ( + \(interaction[.wasRead]) = false AND + \(SQL("\(interaction[.threadId]) = \(threadId)")) + ) + ) AS \(firstUnreadInteractionTableLiteral) ON \(firstUnreadInteractionTableLiteral).\(interactionThreadIdLiteral) = \(thread[.id]) + LEFT JOIN ( + SELECT + \(interaction[.threadId]), + COUNT(*) AS \(ViewModel.threadUnreadCountKey) + FROM \(Interaction.self) + WHERE ( + \(interaction[.wasRead]) = false AND + \(SQL("\(interaction[.threadId]) = \(threadId)")) + ) + GROUP BY \(interaction[.threadId]) + ) AS \(unreadCountTableLiteral) ON \(unreadCountTableLiteral).\(interactionThreadIdLiteral) = \(thread[.id]) + LEFT JOIN ( + SELECT + \(interaction[.threadId]), + COUNT(*) AS \(ViewModel.threadUnreadMentionCountKey) + FROM \(Interaction.self) + WHERE ( + \(interaction[.wasRead]) = false AND + \(interaction[.hasMention]) = true AND + \(SQL("\(interaction[.threadId]) = \(threadId)")) + ) + GROUP BY \(interaction[.threadId]) + ) AS \(unreadMentionCountTableLiteral) ON \(unreadMentionCountTableLiteral).\(interactionThreadIdLiteral) = \(thread[.id]) + + LEFT JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON \(ViewModel.contactProfileKey).\(profileIdColumnLiteral) = \(thread[.id]) + LEFT JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id]) + LEFT JOIN \(GroupMember.self) ON ( + \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND + \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND + \(SQL("\(groupMember[.profileId]) = \(userPublicKey)")) + ) + LEFT JOIN ( + SELECT + \(groupMember[.groupId]), + COUNT(*) AS \(ViewModel.closedGroupUserCountKey) + FROM \(GroupMember.self) + WHERE ( + \(SQL("\(groupMember[.groupId]) = \(threadId)")) AND + \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) + ) + GROUP BY \(groupMember[.groupId]) + ) AS \(closedGroupUserCountTableLiteral) ON \(SQL("\(closedGroupUserCountTableLiteral).\(groupMemberGroupIdColumnLiteral) = \(threadId)")) + LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id]) + + WHERE \(SQL("\(thread[.id]) = \(threadId)")) + GROUP BY \(thread[.id]) + """ + + return request.adapted { db in + let adapters = try splittingRowAdapters(columnCounts: [ + numColumnsBeforeProfiles, + Profile.numberOfSelectedColumns(db) + ]) + + return ScopeAdapter([ + ViewModel.contactProfileString: adapters[1] + ]) + } + } +} + // MARK: - Search Queries public extension ConversationCell.ViewModel { @@ -524,7 +710,7 @@ public extension ConversationCell.ViewModel { let interactionFullTextSearch: SQL = SQL(stringLiteral: Interaction.fullTextSearchTableName) /// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before - /// the `ViewModel.closedGroupProfileFrontKey` entry below otherwise the query will fail to + /// the `ViewModel.contactProfileKey` entry below otherwise the query will fail to /// parse and might throw /// /// Explicitly set default values for the fields ignored for search results @@ -651,7 +837,7 @@ public extension ConversationCell.ViewModel { let searchTermLiteral: SQL = SQL(stringLiteral: searchTerm.lowercased()) /// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before - /// the `ViewModel.closedGroupProfileFrontKey` entry below otherwise the query will fail to + /// the `ViewModel.contactProfileKey` entry below otherwise the query will fail to /// parse and might throw /// /// We use `IFNULL(rank, 100)` because the custom `Note to Self` like comparison will get a null @@ -1005,7 +1191,7 @@ public extension ConversationCell.ViewModel { let profileIdColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.id.name) /// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before - /// the `ViewModel.closedGroupProfileFrontKey` entry below otherwise the query will fail to + /// the `ViewModel.contactProfileKey` entry below otherwise the query will fail to /// parse and might throw /// /// Explicitly set default values for the fields ignored for search results @@ -1018,6 +1204,7 @@ public extension ConversationCell.ViewModel { \(thread[.creationDateTimestamp]) AS \(ViewModel.threadCreationDateTimestampKey), (\(SQL("\(thread[.id]) = \(userPublicKey)"))) AS \(ViewModel.threadIsNoteToSelfKey), + \(thread[.isPinned]) AS \(ViewModel.threadIsPinnedKey), \(contact[.isBlocked]) AS \(ViewModel.threadIsBlockedKey), diff --git a/SessionMessagingKit/Shared Models/MessageInputTypes.swift b/SessionMessagingKit/Shared Models/MessageInputTypes.swift new file mode 100644 index 000000000..3e5769615 --- /dev/null +++ b/SessionMessagingKit/Shared Models/MessageInputTypes.swift @@ -0,0 +1,9 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public enum MessageInputTypes: Equatable { + case all + case textOnly + case none +} diff --git a/SessionUtilitiesKit/Database/GRDBStorage.swift b/SessionUtilitiesKit/Database/GRDBStorage.swift index 8c859e727..30bfdddeb 100644 --- a/SessionUtilitiesKit/Database/GRDBStorage.swift +++ b/SessionUtilitiesKit/Database/GRDBStorage.swift @@ -270,7 +270,9 @@ public final class GRDBStorage { ) } - public func addObserver(_ observer: TransactionObserver) { + public func addObserver(_ observer: TransactionObserver?) { + guard let observer: TransactionObserver = observer else { return } + dbPool.add(transactionObserver: observer) } } diff --git a/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift b/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift new file mode 100644 index 000000000..46fa83059 --- /dev/null +++ b/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift @@ -0,0 +1,1058 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB + +// MARK: - PagedDatabaseObserver + +/// This type manages observation and paging for the provided dataQuery +/// +/// **Note:** We **MUST** have accurate `filterSQL` and `orderSQL` values otherwise the indexing won't work +public class PagedDatabaseObserver: TransactionObserver where ObservedTable: TableRecord & ColumnExpressible & Identifiable, T: FetchableRecordWithRowId & Identifiable { + // MARK: - Variables + + private let pagedTableName: String + private let idColumnName: String + private var pageInfo: Atomic + + private let allObservedTableNames: Set + private let observedInserts: Set + private let observedUpdateColumns: [String: Set] + private let observedDeletes: Set + + private let joinSQL: SQL? + private let filterSQL: SQL + private let orderSQL: SQL + private let dataQuery: (SQL?, SQL?) -> AdaptedFetchRequest> + private let associatedRecords: [ErasedAssociatedRecord] + + private var dataCache: Atomic> = Atomic(DataCache()) + private var isLoadingMoreData: Atomic = Atomic(false) + private let changesInCommit: Atomic> = Atomic([]) + private let onChangeUnsorted: (([T], PagedData.PageInfo) -> ()) + + // MARK: - Initialization + + fileprivate init( + pagedTable: ObservedTable.Type, + pageSize: Int, + idColumn: ObservedTable.Columns, + observedChanges: [PagedData.ObservedChanges], + joinSQL: SQL? = nil, + filterSQL: SQL, + orderSQL: SQL, + dataQuery: @escaping (SQL?, SQL?) -> AdaptedFetchRequest>, + associatedRecords: [ErasedAssociatedRecord] = [], + onChangeUnsorted: @escaping ([T], PagedData.PageInfo) -> (), + initialQueryTarget: PagedData.PageInfo.InternalTarget? + ) { + let associatedTables: Set = associatedRecords.map { $0.databaseTableName }.asSet() + assert(!associatedTables.contains(pagedTable.databaseTableName), "The paged table cannot also exist as an associatedRecord") + + self.pagedTableName = pagedTable.databaseTableName + self.idColumnName = idColumn.name + self.pageInfo = Atomic(PagedData.PageInfo(pageSize: pageSize)) + self.joinSQL = joinSQL + self.filterSQL = filterSQL + self.orderSQL = orderSQL + self.dataQuery = dataQuery + self.associatedRecords = associatedRecords + self.onChangeUnsorted = onChangeUnsorted + + // Combine the various observed changes into a single set + let allObservedChanges: [PagedData.ObservedChanges] = observedChanges + .appending(contentsOf: associatedRecords.flatMap { $0.observedChanges }) + self.allObservedTableNames = allObservedChanges + .map { $0.databaseTableName } + .asSet() + self.observedInserts = allObservedChanges + .filter { $0.events.contains(.insert) } + .map { $0.databaseTableName } + .asSet() + self.observedUpdateColumns = allObservedChanges + .filter { $0.events.contains(.update) } + .reduce(into: [:]) { (prev: inout [String: Set], next: PagedData.ObservedChanges) in + guard !next.columns.isEmpty else { return } + + prev[next.databaseTableName] = next.columns.asSet() + } + self.observedDeletes = allObservedChanges + .filter { $0.events.contains(.delete) } + .map { $0.databaseTableName } + .asSet() + + // Run the initial query if there is one + guard let initialQueryTarget: PagedData.PageInfo.InternalTarget = initialQueryTarget else { return } + + self.load(initialQueryTarget) + } + + // MARK: - TransactionObserver + + public func observes(eventsOfKind eventKind: DatabaseEventKind) -> Bool { + switch eventKind { + case .insert(let tableName): return self.observedInserts.contains(tableName) + case .delete(let tableName): return self.observedDeletes.contains(tableName) + + case .update(let tableName, let columnNames): + return (self.observedUpdateColumns[tableName]? + .intersection(columnNames) + .isEmpty == false) + } + } + + public func databaseDidChange(with event: DatabaseEvent) { + // This will get called whenever the `observes(eventsOfKind:)` returns + // true and will include all changes which occurred in the commit so we + // need to ignore any non-observed tables, unfortunately we also won't + // know if the changes to observed tables are actually relevant yet as + // changes only include table and column info at this stage + guard allObservedTableNames.contains(event.tableName) else { return } + + // The 'event' object only exists during this method so we need to copy the info + // from it, otherwise it will cease to exist after this metod call finishes + changesInCommit.mutate { $0.insert(PagedData.TrackedChange(event: event)) } + } + + // Note: We will process all updates which come through this method even if + // 'onChange' is null because if the UI stops observing and then starts again + // later we don't want to have missed any changes which happened while the UI + // wasn't subscribed (and doing a full re-query seems painful...) + public func databaseDidCommit(_ db: Database) { + var committedChanges: Set = [] + self.changesInCommit.mutate { cachedChanges in + committedChanges = cachedChanges + cachedChanges.removeAll() + } + + // Note: This method will be called regardless of whether there were actually changes + // in the areas we are observing so we want to early-out if there aren't any relevant + // updated rows + guard !committedChanges.isEmpty else { return } + + let orderSQL: SQL = self.orderSQL + let filterSQL: SQL = self.filterSQL + let associatedRecords: [ErasedAssociatedRecord] = self.associatedRecords + + let updateDataAndCallbackIfNeeded: (DataCache, PagedData.PageInfo, Bool) -> () = { [weak self] updatedDataCache, updatedPageInfo, cacheHasChanges in + let associatedDataInfo: [(hasChanges: Bool, data: ErasedAssociatedRecord)] = associatedRecords + .map { associatedRecord in + let hasChanges: Bool = associatedRecord.tryUpdateForDatabaseCommit( + db, + changes: committedChanges, + orderSQL: orderSQL, + filterSQL: filterSQL, + pageInfo: updatedPageInfo + ) + + return (hasChanges, associatedRecord) + } + + // Check if we need to trigger a change callback + guard cacheHasChanges || associatedDataInfo.contains(where: { hasChanges, _ in hasChanges }) else { + return + } + + // If the associated data changed then update the updatedCachedData with the + // updated associated data + var finalUpdatedDataCache: DataCache = updatedDataCache + + associatedDataInfo.forEach { hasChanges, associatedData in + guard cacheHasChanges || hasChanges else { return } + + finalUpdatedDataCache = associatedData.attachAssociatedData(to: finalUpdatedDataCache) + } + + // Update the cache, pageInfo and the change callback + self?.dataCache.mutate { $0 = finalUpdatedDataCache } + self?.pageInfo.mutate { $0 = updatedPageInfo } + + DispatchQueue.main.async { [weak self] in + self?.onChangeUnsorted(finalUpdatedDataCache.values, updatedPageInfo) + } + } + + // Determing if there were any relevant paged data changes + let relevantChanges: Set = committedChanges + .filter { $0.tableName == pagedTableName } + + guard !relevantChanges.isEmpty else { + updateDataAndCallbackIfNeeded(self.dataCache.wrappedValue, self.pageInfo.wrappedValue, false) + return + } + + var updatedPageInfo: PagedData.PageInfo = self.pageInfo.wrappedValue + var updatedDataCache: DataCache = self.dataCache.wrappedValue + let deletionChanges: [Int64] = relevantChanges + .filter { $0.kind == .delete } + .map { $0.rowId } + let oldDataCount: Int = dataCache.wrappedValue.count + + // First remove any items which have been deleted + if !deletionChanges.isEmpty { + updatedDataCache = updatedDataCache.deleting(rowIds: deletionChanges) + + // Make sure there were actually changes + if updatedDataCache.count != oldDataCount { + let dataSizeDiff: Int = (updatedDataCache.count - oldDataCount) + + updatedPageInfo = PagedData.PageInfo( + pageSize: updatedPageInfo.pageSize, + pageOffset: updatedPageInfo.pageOffset, + currentCount: (updatedPageInfo.currentCount + dataSizeDiff), + totalCount: (updatedPageInfo.totalCount + dataSizeDiff) + ) + } + } + + // If there are no inserted/updated rows then trigger the update callback and stop here + let rowIdsToQuery: [Int64] = committedChanges + .filter { $0.kind != .delete } + .map { $0.rowId } + + guard !rowIdsToQuery.isEmpty else { + updateDataAndCallbackIfNeeded(updatedDataCache, updatedPageInfo, !deletionChanges.isEmpty) + return + } + + // Fetch the indexes of the rowIds so we can determine whether they should be added to the screen + let itemIndexes: [Int64] = PagedData.indexes( + db, + rowIds: rowIdsToQuery, + tableName: pagedTableName, + orderSQL: orderSQL, + filterSQL: filterSQL + ) + + // Determine if the indexes for the row ids should be displayed on the screen and remove any + // which shouldn't - values less than 'currentCount' or if there is at least one value less than + // 'currentCount' and the indexes are sequential (ie. more than the current loaded content was + // added at once) + let itemIndexesAreSequential: Bool = (itemIndexes.map { $0 - 1 }.dropFirst() == itemIndexes.dropLast()) + let hasOneValidIndex: Bool = itemIndexes.contains(where: { $0 < updatedPageInfo.currentCount }) + let validRowIds: [Int64] = (itemIndexesAreSequential && hasOneValidIndex ? + rowIdsToQuery : + zip(itemIndexes, rowIdsToQuery) + .filter { index, _ -> Bool in index < updatedPageInfo.currentCount } + .map { _, rowId -> Int64 in rowId } + ) + + // If there are no valid attachment row ids then stop here + guard !validRowIds.isEmpty else { + updateDataAndCallbackIfNeeded(updatedDataCache, updatedPageInfo, !deletionChanges.isEmpty) + return + } + + // Fetch the inserted/updated rows + let additionalFilters: SQL = SQL(validRowIds.contains(Column.rowID)) + let updatedItems: [T] = (try? dataQuery(additionalFilters, nil) + .fetchAll(db)) + .defaulting(to: []) + + // If the inserted/updated rows we irrelevant (associated to data which doesn't pass + // the filter) then trigger the update callback (if there were deletions) and stop here + guard !updatedItems.isEmpty else { + updateDataAndCallbackIfNeeded(updatedDataCache, updatedPageInfo, !deletionChanges.isEmpty) + return + } + + // Process the upserted data + updatedDataCache = updatedDataCache.upserting(items: updatedItems) + + // Update the page info for the upserted data + let dataSizeDiff: Int = (updatedDataCache.count - oldDataCount) + + updatedPageInfo = PagedData.PageInfo( + pageSize: updatedPageInfo.pageSize, + pageOffset: updatedPageInfo.pageOffset, + currentCount: (updatedPageInfo.currentCount + dataSizeDiff), + totalCount: (updatedPageInfo.totalCount + dataSizeDiff) + ) + + updateDataAndCallbackIfNeeded(updatedDataCache, updatedPageInfo, true) + } + + public func databaseDidRollback(_ db: Database) {} + + // MARK: - Functions + + fileprivate func load(_ target: PagedData.PageInfo.InternalTarget) { + // Only allow a single page load at a time + guard !self.isLoadingMoreData.wrappedValue else { return } + + // Prevent more fetching until we have completed adding the page + self.isLoadingMoreData.mutate { $0 = true } + + let currentPageInfo: PagedData.PageInfo = self.pageInfo.wrappedValue + + if case .initialPageAround(_) = target, currentPageInfo.currentCount > 0 { + SNLog("Unable to load initialPageAround if there is already data") + return + } + + // Store locally to avoid giant capture code + let pagedTableName: String = self.pagedTableName + let idColumnName: String = self.idColumnName + let joinSQL: SQL? = self.joinSQL + let filterSQL: SQL = self.filterSQL + let orderSQL: SQL = self.orderSQL + let dataQuery: (SQL?, SQL?) -> AdaptedFetchRequest> = self.dataQuery + + let loadedPage: (data: [T]?, pageInfo: PagedData.PageInfo)? = GRDBStorage.shared.read { [weak self] db in + let totalCount: Int = try dataQuery(filterSQL, nil) + .fetchCount(db) + let queryInfo: (limit: Int, offset: Int, updatedCacheOffset: Int)? = { + switch target { + case .initialPageAround(let targetId): + // If we want to focus on a specific item then we need to find it's index in + // the queried data + let maybeIndex: Int? = PagedData.index( + db, + for: targetId, + tableName: pagedTableName, + idColumn: idColumnName, + requiredJoinSQL: joinSQL, + orderSQL: orderSQL, + filterSQL: filterSQL + ) + + // If we couldn't find the targetId then just load the first page + guard let targetIndex: Int = maybeIndex else { + return (currentPageInfo.pageSize, 0, 0) + } + + let updatedOffset: Int = { + // If the focused item is within the first or last half of the page + // then we still want to retrieve a full page so calculate the offset + // needed to do so (snapping to the ends) + let halfPageSize: Int = Int(floor(Double(currentPageInfo.pageSize) / 2)) + + guard targetIndex > halfPageSize else { return 0 } + guard targetIndex < (totalCount - halfPageSize) else { + return (totalCount - currentPageInfo.pageSize) + } + + return (targetIndex - halfPageSize) + }() + + return (currentPageInfo.pageSize, updatedOffset, updatedOffset) + + case .pageBefore: + let updatedOffset: Int = max(0, (currentPageInfo.pageOffset - currentPageInfo.pageSize)) + + return ( + currentPageInfo.pageSize, + updatedOffset, + updatedOffset + ) + + case .pageAfter: + return ( + currentPageInfo.pageSize, + (currentPageInfo.pageOffset + currentPageInfo.currentCount), + currentPageInfo.pageOffset + ) + + case .untilInclusive(let targetId, let padding): + // If we want to focus on a specific item then we need to find it's index in + // the queried data + let maybeIndex: Int? = PagedData.index( + db, + for: targetId, + tableName: pagedTableName, + idColumn: idColumnName, + orderSQL: orderSQL, + filterSQL: filterSQL + ) + let cacheCurrentEndIndex: Int = (currentPageInfo.pageOffset + currentPageInfo.currentCount) + + // If we couldn't find the targetId or it's already in the cache then do nothing + guard + let targetIndex: Int = maybeIndex.map({ max(0, min(totalCount, $0)) }), + ( + targetIndex < currentPageInfo.pageOffset || + targetIndex > cacheCurrentEndIndex + ) + else { return nil } + + // If the target is before the cached data then load before + if targetIndex < currentPageInfo.pageOffset { + let finalIndex: Int = max(0, (targetIndex - abs(padding))) + + return ( + (currentPageInfo.pageOffset - finalIndex), + finalIndex, + finalIndex + ) + } + + // Otherwise load after + let finalIndex: Int = min(totalCount, (targetIndex + abs(padding))) + + return ( + (finalIndex - cacheCurrentEndIndex), + cacheCurrentEndIndex, + currentPageInfo.pageOffset + ) + } + }() + + // If there is no queryOffset then we already have the data we need so + // early-out (may as well update the 'totalCount' since it may be relevant) + guard let queryInfo: (limit: Int, offset: Int, updatedCacheOffset: Int) = queryInfo else { + return ( + nil, + PagedData.PageInfo( + pageSize: currentPageInfo.pageSize, + pageOffset: currentPageInfo.pageOffset, + currentCount: currentPageInfo.currentCount, + totalCount: totalCount + ) + ) + } + + // Fetch the desired data + let limitSQL: SQL = SQL(stringLiteral: "LIMIT \(queryInfo.limit) OFFSET \(queryInfo.offset)") + let newData: [T] = try dataQuery(filterSQL, limitSQL) + .fetchAll(db) + let updatedLimitInfo: PagedData.PageInfo = PagedData.PageInfo( + pageSize: currentPageInfo.pageSize, + pageOffset: queryInfo.updatedCacheOffset, + currentCount: (currentPageInfo.currentCount + newData.count), + totalCount: totalCount + ) + + // Update the associatedRecords for the newly retrieved data + self?.associatedRecords.forEach { record in + record.updateCache( + db, + rowIds: PagedData.associatedRowIds( + db, + tableName: record.databaseTableName, + pagedTableName: pagedTableName, + pagedTypeRowIds: newData.map { $0.rowId }, + joinToPagedType: record.joinToPagedType + ), + hasOtherChanges: false + ) + } + + return (newData, updatedLimitInfo) + } + + // Unwrap the updated data + guard + let loadedPageData: [T] = loadedPage?.data, + let updatedPageInfo: PagedData.PageInfo = loadedPage?.pageInfo + else { + // It's possible to get updated page info without having updated data, in that case + // we do want to update the cache but probably don't need to trigger the change callback + if let updatedPageInfo: PagedData.PageInfo = loadedPage?.pageInfo { + self.pageInfo.mutate { $0 = updatedPageInfo } + } + return + } + + // Attach any associated data to the loadedPageData + var associatedLoadedData: DataCache = DataCache(items: loadedPageData) + + self.associatedRecords.forEach { record in + associatedLoadedData = record.attachAssociatedData(to: associatedLoadedData) + } + + // Update the cache and pageInfo + self.dataCache.mutate { $0 = $0.upserting(items: associatedLoadedData.values) } + self.pageInfo.mutate { $0 = updatedPageInfo } + + let triggerUpdates: () -> () = { [weak self, dataCache = self.dataCache.wrappedValue] in + self?.onChangeUnsorted(dataCache.values, updatedPageInfo) + self?.isLoadingMoreData.mutate { $0 = false } + } + + // Make sure the updates run on the main thread + guard Thread.isMainThread else { + DispatchQueue.main.async { triggerUpdates() } + return + } + + triggerUpdates() + } +} + +// MARK: - Convenience + +public extension PagedDatabaseObserver { + fileprivate static func initialQueryTarget( + for initialFocusedId: ID?, + skipInitialQuery: Bool + ) -> PagedData.PageInfo.InternalTarget? { + // Determine if we want to laod the first page immediately (this is generally needed + // to prevent transitions from looking buggy) + guard !skipInitialQuery else { return nil } + + switch initialFocusedId { + case .some(let targetId): return .initialPageAround(id: targetId.sqlExpression) + + // If we don't have a `initialFocusedId` then default to `.pageBefore` (it'll query + // from a `0` offset + case .none: return .pageBefore + } + } + + convenience init( + pagedTable: ObservedTable.Type, + pageSize: Int, + idColumn: ObservedTable.Columns, + initialFocusedId: ObservedTable.ID? = nil, + observedChanges: [PagedData.ObservedChanges], + joinSQL: SQL? = nil, + filterSQL: SQL, + orderSQL: SQL, + dataQuery: @escaping (SQL?, SQL?) -> AdaptedFetchRequest>, + associatedRecords: [ErasedAssociatedRecord] = [], + onChangeUnsorted: @escaping ([T], PagedData.PageInfo) -> (), + skipInitialQuery: Bool = false + ) where ObservedTable.ID: SQLExpressible { + self.init( + pagedTable: pagedTable, + pageSize: pageSize, + idColumn: idColumn, + observedChanges: observedChanges, + joinSQL: joinSQL, + filterSQL: filterSQL, + orderSQL: orderSQL, + dataQuery: dataQuery, + associatedRecords: associatedRecords, + onChangeUnsorted: onChangeUnsorted, + initialQueryTarget: PagedDatabaseObserver.initialQueryTarget( + for: initialFocusedId, + skipInitialQuery: skipInitialQuery + ) + ) + } + + convenience init( + pagedTable: ObservedTable.Type, + pageSize: Int, + idColumn: ObservedTable.Columns, + initialFocusedId: ObservedTable.ID? = nil, + observedChanges: [PagedData.ObservedChanges], + joinSQL: SQL? = nil, + filterSQL: SQL, + orderSQL: SQL, + dataQuery: @escaping (SQL?, SQL?) -> SQLRequest, + associatedRecords: [ErasedAssociatedRecord] = [], + onChangeUnsorted: @escaping ([T], PagedData.PageInfo) -> (), + skipInitialQuery: Bool = false + ) where ObservedTable.ID: SQLExpressible { + self.init( + pagedTable: pagedTable, + pageSize: pageSize, + idColumn: idColumn, + observedChanges: observedChanges, + joinSQL: joinSQL, + filterSQL: filterSQL, + orderSQL: orderSQL, + dataQuery: { additionalFilters, limit in + dataQuery(additionalFilters, limit).adapted { _ in ScopeAdapter([:]) } + }, + associatedRecords: associatedRecords, + onChangeUnsorted: onChangeUnsorted, + initialQueryTarget: PagedDatabaseObserver.initialQueryTarget( + for: initialFocusedId, + skipInitialQuery: skipInitialQuery + ) + ) + } + + convenience init( + pagedTable: ObservedTable.Type, + pageSize: Int, + idColumn: ObservedTable.Columns, + initialFocusedId: ID? = nil, + observedChanges: [PagedData.ObservedChanges], + joinSQL: SQL? = nil, + filterSQL: SQL, + orderSQL: SQL, + dataQuery: @escaping (SQL?, SQL?) -> AdaptedFetchRequest>, + associatedRecords: [ErasedAssociatedRecord] = [], + onChangeUnsorted: @escaping ([T], PagedData.PageInfo) -> (), + skipInitialQuery: Bool = false + ) where ObservedTable.ID == Optional, ID: SQLExpressible { + self.init( + pagedTable: pagedTable, + pageSize: pageSize, + idColumn: idColumn, + observedChanges: observedChanges, + joinSQL: joinSQL, + filterSQL: filterSQL, + orderSQL: orderSQL, + dataQuery: dataQuery, + associatedRecords: associatedRecords, + onChangeUnsorted: onChangeUnsorted, + initialQueryTarget: PagedDatabaseObserver.initialQueryTarget( + for: initialFocusedId, + skipInitialQuery: skipInitialQuery + ) + ) + } + + convenience init( + pagedTable: ObservedTable.Type, + pageSize: Int, + idColumn: ObservedTable.Columns, + initialFocusedId: ID? = nil, + observedChanges: [PagedData.ObservedChanges], + joinSQL: SQL? = nil, + filterSQL: SQL, + orderSQL: SQL, + dataQuery: @escaping (SQL?, SQL?) -> SQLRequest, + associatedRecords: [ErasedAssociatedRecord] = [], + onChangeUnsorted: @escaping ([T], PagedData.PageInfo) -> (), + skipInitialQuery: Bool = false + ) where ObservedTable.ID == Optional, ID: SQLExpressible { + self.init( + pagedTable: pagedTable, + pageSize: pageSize, + idColumn: idColumn, + observedChanges: observedChanges, + joinSQL: joinSQL, + filterSQL: filterSQL, + orderSQL: orderSQL, + dataQuery: { additionalFilters, limit in + dataQuery(additionalFilters, limit).adapted { _ in ScopeAdapter([:]) } + }, + associatedRecords: associatedRecords, + onChangeUnsorted: onChangeUnsorted, + initialQueryTarget: PagedDatabaseObserver.initialQueryTarget( + for: initialFocusedId, + skipInitialQuery: skipInitialQuery + ) + ) + } + + func load(_ target: PagedData.PageInfo.Target) where ObservedTable.ID: SQLExpressible { + self.load(target.internalTarget) + } + + func load(_ target: PagedData.PageInfo.Target) where ObservedTable.ID == Optional, ID: SQLExpressible { + self.load(target.internalTarget) + } +} + +// MARK: - FetchableRecordWithRowId + +public protocol FetchableRecordWithRowId: FetchableRecord { + var rowId: Int64 { get } +} + +// MARK: - ErasedAssociatedRecord + +public protocol ErasedAssociatedRecord { + var databaseTableName: String { get } + var observedChanges: [PagedData.ObservedChanges] { get } + var joinToPagedType: SQL { get } + + func tryUpdateForDatabaseCommit( + _ db: Database, + changes: Set, + orderSQL: SQL, + filterSQL: SQL, + pageInfo: PagedData.PageInfo + ) -> Bool + @discardableResult func updateCache(_ db: Database, rowIds: [Int64], hasOtherChanges: Bool) -> Bool + func attachAssociatedData(to unassociatedCache: DataCache) -> DataCache +} + +// MARK: - DataCache + +public struct DataCache { + /// This is a map of `[RowId: Value]` + public let data: [Int64: T] + + /// This is a map of `[(Identifiable)id: RowId]` and can be used to find the RowId for + /// a cached value given it's `Identifiable` `id` value + public let lookup: [AnyHashable: Int64] + + public var count: Int { data.count } + public var values: [T] { Array(data.values) } + + // MARK: - Initialization + + public init( + data: [Int64: T] = [:], + lookup: [AnyHashable: Int64] = [:] + ) { + self.data = data + self.lookup = lookup + } + + fileprivate init(items: [T]) { + self = DataCache().upserting(items: items) + } + + // MARK: - Functions + + public func deleting(rowIds: [Int64]) -> DataCache { + var updatedData: [Int64: T] = self.data + var updatedLookup: [AnyHashable: Int64] = self.lookup + + rowIds.forEach { rowId in + if let cachedItem: T = updatedData.removeValue(forKey: rowId) { + updatedLookup.removeValue(forKey: cachedItem.id) + } + } + + return DataCache( + data: updatedData, + lookup: updatedLookup + ) + } + + public func upserting(_ item: T) -> DataCache { + return upserting(items: [item]) + } + + public func upserting(items: [T]) -> DataCache { + var updatedData: [Int64: T] = self.data + var updatedLookup: [AnyHashable: Int64] = self.lookup + + items.forEach { item in + updatedData[item.rowId] = item + updatedLookup[item.id] = item.rowId + } + + return DataCache( + data: updatedData, + lookup: updatedLookup + ) + } +} + +// MARK: - PagedData + +public enum PagedData { + // MARK: - PageInfo + + public struct PageInfo { + /// This type is identical to the 'Target' type but has it's 'SQLExpressible' requirement removed + fileprivate enum InternalTarget { + case initialPageAround(id: SQLExpression) + case pageBefore + case pageAfter + case untilInclusive(id: SQLExpression, padding: Int) + } + + public enum Target { + /// This will attempt to load a page of data around a specified id + /// + /// **Note:** This target will only work if there is no other data in the cache + case initialPageAround(id: ID) + + /// This will attempt to load a page of data before the first item in the cache + case pageBefore + + /// This will attempt to load a page of data after the last item in the cache + case pageAfter + + /// This will attempt to load all data between what is currently in the cache until the + /// specified id (plus the padding amount) + /// + /// **Note:** If the id is already within the cache then this will do nothing (even if + /// the padding would mean more data should be loaded) + case untilInclusive(id: ID, padding: Int) + + fileprivate var internalTarget: InternalTarget { + switch self { + case .initialPageAround(let id): return .initialPageAround(id: id.sqlExpression) + case .pageBefore: return .pageBefore + case .pageAfter: return .pageAfter + case .untilInclusive(let id, let padding): + return .untilInclusive(id: id.sqlExpression, padding: padding) + } + } + } + + public let pageSize: Int + public let pageOffset: Int + public let currentCount: Int + public let totalCount: Int + + // MARK: - Initizliation + + public init( + pageSize: Int, + pageOffset: Int = 0, + currentCount: Int = 0, + totalCount: Int = 0 + ) { + self.pageSize = pageSize + self.pageOffset = pageOffset + self.currentCount = currentCount + self.totalCount = totalCount + } + } + + // MARK: - ObservedChanges + + /// This type contains the information needed to define what changes should be included when observing + /// changes to a database + /// + /// - Parameters: + /// - table: The table whose changes should be observed + /// - events: The database events which should be observed + /// - columns: The specific columns which should trigger changes (**Note:** These only apply to `update` changes) + public struct ObservedChanges { + public let databaseTableName: String + public let events: [DatabaseEvent.Kind] + public let columns: [String] + + public init( + table: T.Type, + events: [DatabaseEvent.Kind] = [.insert, .update, .delete], + columns: [T.Columns] + ) { + self.databaseTableName = table.databaseTableName + self.events = events + self.columns = columns.map { $0.name } + } + } + + // MARK: - TrackedChange + + public struct TrackedChange: Hashable { + let tableName: String + let kind: DatabaseEvent.Kind + let rowId: Int64 + + init(event: DatabaseEvent) { + self.tableName = event.tableName + self.kind = event.kind + self.rowId = event.rowID + } + } + + // MARK: - Internal Functions + + fileprivate static func index( + _ db: Database, + for id: ID, + tableName: String, + idColumn: String, + requiredJoinSQL: SQL? = nil, + orderSQL: SQL, + filterSQL: SQL, + joinToPagedType: SQL? = nil + ) -> Int? { + let tableNameLiteral: SQL = SQL(stringLiteral: tableName) + let idColumnLiteral: SQL = SQL(stringLiteral: idColumn) + let request: SQLRequest = """ + SELECT + (data.rowIndex - 1) AS rowIndex -- Converting from 1-Indexed to 0-indexed + FROM ( + SELECT + \(tableNameLiteral).\(idColumnLiteral) AS \(idColumnLiteral), + ROW_NUMBER() OVER (ORDER BY \(orderSQL)) AS rowIndex + FROM \(tableNameLiteral) + \(requiredJoinSQL ?? "") + \(joinToPagedType ?? "") + WHERE \(filterSQL) + ) AS data + WHERE \(SQL("data.\(idColumnLiteral) = \(id)")) + """ + + return try? request.fetchOne(db) + } + + /// Returns the indexes the requested rowIds will have in the paged query + /// + /// **Note:** If the `associatedRecord` is null then the index for the rowId of the paged data type will be returned + fileprivate static func indexes( + _ db: Database, + rowIds: [Int64], + tableName: String, + requiredJoinSQL: SQL? = nil, + orderSQL: SQL, + filterSQL: SQL, + joinToPagedType: SQL? = nil + ) -> [Int64] { + let tableNameLiteral: SQL = SQL(stringLiteral: tableName) + let request: SQLRequest = """ + SELECT + (data.rowIndex - 1) AS rowIndex -- Converting from 1-Indexed to 0-indexed + FROM ( + SELECT + \(tableNameLiteral).rowid AS rowid, + ROW_NUMBER() OVER (ORDER BY \(orderSQL)) AS rowIndex + FROM \(tableNameLiteral) + \(requiredJoinSQL ?? "") + \(joinToPagedType ?? "") + WHERE \(filterSQL) + ) AS data + WHERE \(SQL("data.rowid IN \(rowIds)")) + """ + + return (try? request.fetchAll(db)) + .defaulting(to: []) + } + + /// Returns the rowIds for the associated types based on the specified pagedTypeRowIds + fileprivate static func associatedRowIds( + _ db: Database, + tableName: String, + pagedTableName: String, + pagedTypeRowIds: [Int64], + joinToPagedType: SQL + ) -> [Int64] { + let tableNameLiteral: SQL = SQL(stringLiteral: tableName) + let pagedTableNameLiteral: SQL = SQL(stringLiteral: pagedTableName) + let request: SQLRequest = """ + SELECT \(tableNameLiteral).rowid AS rowid + FROM \(tableNameLiteral) + \(joinToPagedType) + WHERE \(pagedTableNameLiteral).rowId IN \(pagedTypeRowIds) + """ + + return (try? request.fetchAll(db)) + .defaulting(to: []) + } +} + +// MARK: - AssociatedRecord + +public class AssociatedRecord: ErasedAssociatedRecord where T: FetchableRecordWithRowId & Identifiable, PagedType: FetchableRecordWithRowId & Identifiable { + public let databaseTableName: String + public let observedChanges: [PagedData.ObservedChanges] + public let joinToPagedType: SQL + + fileprivate let dataCache: Atomic> = Atomic(DataCache()) + fileprivate let dataQuery: (SQL?) -> AdaptedFetchRequest> + fileprivate let associateData: (DataCache, DataCache) -> DataCache + + // MARK: - Initialization + + public init( + trackedAgainst: Table.Type, + observedChanges: [PagedData.ObservedChanges], + dataQuery: @escaping (SQL?) -> AdaptedFetchRequest>, + joinToPagedType: SQL, + associateData: @escaping (DataCache, DataCache) -> DataCache + ) { + self.databaseTableName = trackedAgainst.databaseTableName + self.observedChanges = observedChanges + self.dataQuery = dataQuery + self.joinToPagedType = joinToPagedType + self.associateData = associateData + } + + convenience init( + trackedAgainst: Table.Type, + observedChanges: [PagedData.ObservedChanges], + dataQuery: @escaping (SQL?) -> SQLRequest, + joinToPagedType: SQL, + associateData: @escaping (DataCache, DataCache) -> DataCache + ) { + self.init( + trackedAgainst: trackedAgainst, + observedChanges: observedChanges, + dataQuery: { additionalFilters in + dataQuery(additionalFilters).adapted { _ in ScopeAdapter([:]) } + }, + joinToPagedType: joinToPagedType, + associateData: associateData + ) + } + + // MARK: - AssociatedRecord + + public func tryUpdateForDatabaseCommit( + _ db: Database, + changes: Set, + orderSQL: SQL, + filterSQL: SQL, + pageInfo: PagedData.PageInfo + ) -> Bool { + // Ignore any changes which aren't relevant to this type + let relevantChanges: Set = changes + .filter { $0.tableName == databaseTableName } + + guard !relevantChanges.isEmpty else { return false } + + // First remove any items which have been deleted + let oldCount: Int = self.dataCache.wrappedValue.count + let deletionChanges: [Int64] = relevantChanges + .filter { $0.kind == .delete } + .map { $0.rowId } + + dataCache.mutate { $0 = $0.deleting(rowIds: deletionChanges) } + + // Get an updated count to avoid locking the dataCache unnecessarily + let countAfterDeletions: Int = self.dataCache.wrappedValue.count + + // If there are no inserted/updated rows then trigger the update callback and stop here + let rowIdsToQuery: [Int64] = relevantChanges + .filter { $0.kind != .delete } + .map { $0.rowId } + + guard !rowIdsToQuery.isEmpty else { return (oldCount != countAfterDeletions) } + + // Fetch the indexes of the rowIds so we can determine whether they should be added to the screen + let itemIndexes: [Int64] = PagedData.indexes( + db, + rowIds: rowIdsToQuery, + tableName: databaseTableName, + orderSQL: orderSQL, + filterSQL: filterSQL, + joinToPagedType: joinToPagedType + ) + + // Determine if the indexes for the row ids should be displayed on the screen and remove any + // which shouldn't - values less than 'currentCount' or if there is at least one value less than + // 'currentCount' and the indexes are sequential (ie. more than the current loaded content was + // added at once) + let itemIndexesAreSequential: Bool = (itemIndexes.map { $0 - 1 }.dropFirst() == itemIndexes.dropLast()) + let hasOneValidIndex: Bool = itemIndexes.contains(where: { $0 < pageInfo.currentCount }) + let validRowIds: [Int64] = (itemIndexesAreSequential && hasOneValidIndex ? + itemIndexes : + zip(itemIndexes, rowIdsToQuery) + .filter { index, _ -> Bool in index < pageInfo.currentCount } + .map { _, rowId -> Int64 in rowId } + ) + + // Attempt to update the cache with the `validRowIds` array + return updateCache( + db, + rowIds: validRowIds, + hasOtherChanges: (oldCount != countAfterDeletions) + ) + } + + @discardableResult public func updateCache(_ db: Database, rowIds: [Int64], hasOtherChanges: Bool = false) -> Bool { + // If there are no rowIds then stop here + guard !rowIds.isEmpty else { return hasOtherChanges } + + // Fetch the inserted/updated rows + let additionalFilters: SQL = SQL(rowIds.contains(Column.rowID)) + let updatedItems: [T] = (try? dataQuery(additionalFilters) + .fetchAll(db)) + .defaulting(to: []) + + // If the inserted/updated rows we irrelevant (eg. associated to another thread, a quote or a link + // preview) then trigger the update callback (if there were deletions) and stop here + guard !updatedItems.isEmpty else { return hasOtherChanges } + + // Process the upserted data (assume at least one value changed) + dataCache.mutate { $0 = $0.upserting(items: updatedItems) } + + return true + } + + public func attachAssociatedData(to unassociatedCache: DataCache) -> DataCache { + guard let typedCache: DataCache = unassociatedCache as? DataCache else { + return unassociatedCache + } + + return (associateData(dataCache.wrappedValue, typedCache) as? DataCache) + .defaulting(to: unassociatedCache) + } +} diff --git a/SessionUtilitiesKit/Database/Utilities/Database+Utilities.swift b/SessionUtilitiesKit/Database/Utilities/Database+Utilities.swift index 810a862b6..09a6cb7a5 100644 --- a/SessionUtilitiesKit/Database/Utilities/Database+Utilities.swift +++ b/SessionUtilitiesKit/Database/Utilities/Database+Utilities.swift @@ -16,7 +16,7 @@ public extension Database { } } - public func makeFTS5Pattern(rawPattern: String, forTable table: T.Type) throws -> FTS5Pattern where T: TableRecord, T: ColumnExpressible { + func makeFTS5Pattern(rawPattern: String, forTable table: T.Type) throws -> FTS5Pattern where T: TableRecord, T: ColumnExpressible { return try makeFTS5Pattern(rawPattern: rawPattern, forTable: table.databaseTableName) } } diff --git a/SessionUtilitiesKit/General/Dictionary+Description.swift b/SessionUtilitiesKit/General/Dictionary+Utilities.swift similarity index 100% rename from SessionUtilitiesKit/General/Dictionary+Description.swift rename to SessionUtilitiesKit/General/Dictionary+Utilities.swift