diff --git a/Podfile.lock b/Podfile.lock index f4f18dfca..fc074549d 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -27,7 +27,7 @@ PODS: - DifferenceKit/Core (1.2.0) - DifferenceKit/UIKitExtension (1.2.0): - DifferenceKit/Core - - GRDB.swift/SQLCipher (5.24.0): + - GRDB.swift/SQLCipher (5.24.1): - SQLCipher (>= 3.4.0) - Mantle (2.1.0): - Mantle/extobjc (= 2.1.0) @@ -203,7 +203,7 @@ SPEC CHECKSUMS: CryptoSwift: a532e74ed010f8c95f611d00b8bbae42e9fe7c17 Curve25519Kit: e63f9859ede02438ae3defc5e1a87e09d1ec7ee6 DifferenceKit: 5659c430bb7fe45876fa32ce5cba5d6167f0c805 - GRDB.swift: 7ecc8799aaa97cf1fbbcfa9d75821aa920cb713f + GRDB.swift: b3180ce2135fc06a453297889b746b1478c4d8c7 Mantle: 2fa750afa478cd625a94230fbf1c13462f29395b NVActivityIndicatorView: 1f6c5687f1171810aa27a3296814dc2d7dec3667 OpenSSL-Universal: e7311447fd2419f57420c79524b641537387eff2 diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index 8c7d2ea0d..98901985f 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -180,9 +180,9 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { public var onInteractionChange: (([SectionModel]) -> ())? private func process(data: [MessageViewModel], for pageInfo: PagedData.PageInfo) -> [SectionModel] { - let typingIndicator: MessageViewModel? = data.first(where: { $0.isTypingIndicator }) + let typingIndicator: MessageViewModel? = data.first(where: { $0.isTypingIndicator == true }) let sortedData: [MessageViewModel] = data - .filter { !$0.isTypingIndicator } + .filter { $0.isTypingIndicator != true } .sorted { lhs, rhs -> Bool in lhs.timestampMs < rhs.timestampMs } // We load messages from newest to oldest so having a pageOffset larger than zero means diff --git a/Session/Home/HomeVC.swift b/Session/Home/HomeVC.swift index 7cd8d113b..b2469e626 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -34,6 +34,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve result.subtitle = NSLocalizedString("view_seed_reminder_subtitle_1", comment: "") result.setProgress(0.8, animated: false) result.delegate = self + result.isHidden = !self.viewModel.state.showViewedSeedBanner return result }() @@ -131,13 +132,10 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve setUpNavBarSessionHeading() // Recovery phrase reminder - let hasViewedSeed = UserDefaults.standard[.hasViewedSeed] - if !hasViewedSeed { - view.addSubview(seedReminderView) - seedReminderView.pin(.leading, to: .leading, of: view) - seedReminderView.pin(.top, to: .top, of: view) - seedReminderView.pin(.trailing, to: .trailing, of: view) - } + view.addSubview(seedReminderView) + seedReminderView.pin(.leading, to: .leading, of: view) + seedReminderView.pin(.top, to: .top, of: view) + seedReminderView.pin(.trailing, to: .trailing, of: view) // Loading conversations label view.addSubview(loadingConversationsLabel) @@ -149,9 +147,10 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve // Table view view.addSubview(tableView) tableView.pin(.leading, to: .leading, of: view) - if !hasViewedSeed { + if self.viewModel.state.showViewedSeedBanner { tableViewTopConstraint = tableView.pin(.top, to: .bottom, of: seedReminderView) - } else { + } + else { tableViewTopConstraint = tableView.pin(.top, to: .top, of: view, withInset: Values.smallSpacing) } tableView.pin(.trailing, to: .trailing, of: view) @@ -187,11 +186,6 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve name: UIApplication.didEnterBackgroundNotification, object: nil ) - notificationCenter.addObserver(self, selector: #selector(handleProfileDidChangeNotification(_:)), name: .otherUsersProfileDidChange, object: nil) - notificationCenter.addObserver(self, selector: #selector(handleLocalProfileDidChangeNotification(_:)), name: .localProfileDidChange, object: nil) - notificationCenter.addObserver(self, selector: #selector(handleSeedViewedNotification(_:)), name: .seedViewed, object: nil) - notificationCenter.addObserver(self, selector: #selector(handleBlockedContactsUpdatedNotification(_:)), name: .blockedContactsUpdated, object: nil) - // Start polling if needed (i.e. if the user just created or restored their Session ID) if Identity.userExists(), let appDelegate: AppDelegate = UIApplication.shared.delegate as? AppDelegate { appDelegate.startPollersIfNeeded() @@ -235,21 +229,28 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve private func startObservingChanges() { // Start observing for data changes dataChangeObservable = GRDBStorage.shared.start( - viewModel.observableViewData, + viewModel.observableState, + // If we haven't done the initial load the trigger it immediately (blocking the main + // thread so we remain on the launch screen until it completes to be consistent with + // the old behaviour) + scheduling: (hasLoadedInitialData ? + .async(onQueue: .main) : + .immediate + ), onError: { _ in }, - onChange: { [weak self] viewData in + onChange: { [weak self] state in // The default scheduler emits changes on the main thread - self?.handleUpdates(viewData) + self?.handleUpdates(state) } ) } - private func handleUpdates(_ updatedViewData: [ArraySection]) { + private func handleUpdates(_ updatedState: HomeViewModel.State) { // 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 { hasLoadedInitialData = true - UIView.performWithoutAnimation { handleUpdates(updatedViewData) } + UIView.performWithoutAnimation { handleUpdates(updatedState) } return } @@ -258,48 +259,42 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve // Show the empty state if there is no data emptyStateView.isHidden = ( - !updatedViewData.isEmpty && - updatedViewData.contains(where: { !$0.elements.isEmpty }) + !updatedState.sections.isEmpty && + updatedState.sections.contains(where: { !$0.elements.isEmpty }) ) + // Update the 'view seed' UI + if updatedState.showViewedSeedBanner != self.viewModel.state.showViewedSeedBanner { + tableViewTopConstraint.isActive = false + seedReminderView.isHidden = !updatedState.showViewedSeedBanner + + if updatedState.showViewedSeedBanner { + tableViewTopConstraint = tableView.pin(.top, to: .bottom, of: seedReminderView) + } + else { + tableViewTopConstraint = tableView.pin(.top, to: .top, of: view, withInset: Values.smallSpacing) + } + } + // Reload the table content (animate changes after the first load) tableView.reload( - using: StagedChangeset(source: viewModel.viewData, target: updatedViewData), + using: StagedChangeset(source: viewModel.state.sections, target: updatedState.sections), deleteSectionsAnimation: .none, insertSectionsAnimation: .none, reloadSectionsAnimation: .none, deleteRowsAnimation: .bottom, insertRowsAnimation: .top, reloadRowsAnimation: .none, - interrupt: { - print("Interrupt change check: \($0.changeCount)") - return $0.changeCount > 100 - } // Prevent too many changes from causing performance issues - ) { [weak self] updatedData in - self?.viewModel.updateData(updatedData) + interrupt: { $0.changeCount > 100 } // Prevent too many changes from causing performance issues + ) { [weak self] updatedSections in + guard let currentState: HomeViewModel.State = self?.viewModel.state else { return } + + self?.viewModel.updateState(currentState.with(sections: updatedSections)) } - } - - @objc private func handleProfileDidChangeNotification(_ notification: Notification) { - DispatchQueue.main.async { - self.tableView.reloadData() // TODO: Just reload the affected cell - } - } - - @objc private func handleLocalProfileDidChangeNotification(_ notification: Notification) { - DispatchQueue.main.async { - self.updateNavBarButtons() - } - } - - @objc private func handleSeedViewedNotification(_ notification: Notification) { - tableViewTopConstraint.isActive = false - tableViewTopConstraint = tableView.pin(.top, to: .top, of: view, withInset: Values.smallSpacing) - seedReminderView.removeFromSuperview() - } - - @objc private func handleBlockedContactsUpdatedNotification(_ notification: Notification) { - self.tableView.reloadData() // TODO: Just reload the affected cell + + self.viewModel.updateState( + self.viewModel.state.with(showViewedSeedBanner: updatedState.showViewedSeedBanner) + ) } private func updateNavBarButtons() { @@ -358,15 +353,15 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve // MARK: - UITableViewDataSource func numberOfSections(in tableView: UITableView) -> Int { - return viewModel.viewData.count + return viewModel.state.sections.count } func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return viewModel.viewData[section].elements.count + return viewModel.state.sections[section].elements.count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let section: ArraySection = viewModel.viewData[indexPath.section] + let section: ArraySection = viewModel.state.sections[indexPath.section] switch section.model { case .messageRequests: @@ -386,7 +381,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) - let section: ArraySection = viewModel.viewData[indexPath.section] + let section: ArraySection = viewModel.state.sections[indexPath.section] switch section.model { case .messageRequests: @@ -404,11 +399,11 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve } func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? { - let section: ArraySection = viewModel.viewData[indexPath.section] + let section: ArraySection = viewModel.state.sections[indexPath.section] switch section.model { case .messageRequests: - let hide = UITableViewRowAction(style: .destructive, title: NSLocalizedString("TXT_HIDE_TITLE", comment: "")) { [weak self] _, _ in + let hide = UITableViewRowAction(style: .destructive, title: "TXT_HIDE_TITLE".localized()) { [weak self] _, _ in GRDBStorage.shared.write { db in db[.hasHiddenMessageRequests] = true } // Animate the row removal diff --git a/Session/Home/HomeViewModel.swift b/Session/Home/HomeViewModel.swift index a17730a42..95c3429ee 100644 --- a/Session/Home/HomeViewModel.swift +++ b/Session/Home/HomeViewModel.swift @@ -11,8 +11,26 @@ public class HomeViewModel { case threads } + public struct State: Equatable { + let showViewedSeedBanner: Bool + let sections: [ArraySection] + + func with( + showViewedSeedBanner: Bool? = nil, + sections: [ArraySection]? = nil + ) -> State { + return State( + showViewedSeedBanner: (showViewedSeedBanner ?? self.showViewedSeedBanner), + sections: (sections ?? self.sections) + ) + } + } + /// This value is the current state of the view - public private(set) var viewData: [ArraySection] = [] + public private(set) var state: State = State( + showViewedSeedBanner: !GRDBStorage.shared[.hasViewedSeed], + sections: [] + ) /// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise /// performance https://github.com/groue/GRDB.swift#valueobservation-performance @@ -21,7 +39,7 @@ public class HomeViewModel { /// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own /// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`) /// just in case the database has changed between the two reads - unfortunately it doesn't look like there is a way to prevent this - public lazy var observableViewData = ValueObservation + public lazy var observableState = ValueObservation .tracking( regions: [ // We explicitly define the regions we want to track as the automatic detection @@ -34,7 +52,10 @@ public class HomeViewModel { .mutedUntilTimestamp, .onlyNotifyForMentions ), - Setting.filter(id: Setting.BoolKey.hasHiddenMessageRequests.rawValue), + Setting.filter(ids: [ + Setting.BoolKey.hasHiddenMessageRequests.rawValue, + Setting.BoolKey.hasViewedSeed.rawValue + ]), Contact.select(.isBlocked, .isApproved), // 'isApproved' for message requests Profile.select(.name, .nickname, .profilePictureFileName), ClosedGroup.select(.name), @@ -48,7 +69,8 @@ public class HomeViewModel { RecipientState.select(.state), ThreadTypingIndicator.select(.threadId) ], - fetch: { db -> [ArraySection] in + fetch: { db -> State in + let hasViewedSeed: Bool = db[.hasViewedSeed] let userPublicKey: String = getUserHexEncodedPublicKey(db) let unreadMessageRequestCount: Int = try SessionThread .unreadMessageRequestsCountQuery(userPublicKey: userPublicKey) @@ -59,31 +81,34 @@ public class HomeViewModel { .homeQuery(userPublicKey: userPublicKey) .fetchAll(db) - return [ - ArraySection( - model: .messageRequests, - elements: [ - // If there are no unread message requests then hide the message request banner - (finalUnreadMessageRequestCount == 0 ? - nil : - SessionThreadViewModel( - unreadCount: UInt(finalUnreadMessageRequestCount) + return State( + showViewedSeedBanner: !hasViewedSeed, + sections: [ + ArraySection( + model: .messageRequests, + elements: [ + // If there are no unread message requests then hide the message request banner + (finalUnreadMessageRequestCount == 0 ? + nil : + SessionThreadViewModel( + unreadCount: UInt(finalUnreadMessageRequestCount) + ) ) - ) - ].compactMap { $0 } - ), - ArraySection( - model: .threads, - elements: threads - ) - ] + ].compactMap { $0 } + ), + ArraySection( + model: .threads, + elements: threads + ) + ] + ) } ) .removeDuplicates() // MARK: - Functions - public func updateData(_ updatedData: [ArraySection]) { - self.viewData = updatedData + public func updateState(_ updatedState: State) { + self.state = updatedState } } diff --git a/Session/Notifications/AppNotifications.swift b/Session/Notifications/AppNotifications.swift index 337a4d4a1..0315b8eeb 100644 --- a/Session/Notifications/AppNotifications.swift +++ b/Session/Notifications/AppNotifications.swift @@ -152,7 +152,8 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { public func notifyUser(_ db: Database, for interaction: Interaction, in thread: SessionThread, isBackgroundPoll: Bool) { guard Date().timeIntervalSince1970 < (thread.mutedUntilTimestamp ?? 0) else { return } - let isMessageRequest = thread.isMessageRequest(db) + let userPublicKey: String = getUserHexEncodedPublicKey(db) + let isMessageRequest: Bool = thread.isMessageRequest(db) // If the thread is a message request and the user hasn't hidden message requests then we need // to check if this is the only message request thread (group threads can't be message requests @@ -160,8 +161,10 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { // notification regardless of how many message requests there are) if thread.variant == .contact { if isMessageRequest && !db[.hasHiddenMessageRequests] { - let numMessageRequestThreads: Int? = try? SessionThread.messageRequestThreads(db) - .fetchCount(db) + let numMessageRequestThreads: Int? = (try? SessionThread + .messageRequestsCountQuery(userPublicKey: userPublicKey) + .fetchOne(db)) + .defaulting(to: 0) // Allow this to show a notification if there are no message requests (ie. this is the first one) guard (numMessageRequestThreads ?? 0) == 0 else { return } @@ -244,7 +247,10 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { notificationBody = "MESSAGE_REQUESTS_NOTIFICATION".localized() } - assert((notificationBody ?? notificationTitle) != nil) + guard notificationBody != nil || notificationTitle != nil else { + SNLog("AppNotifications error: No notification content") + return + } // Don't reply from lockscreen if anyone in this conversation is // "no longer verified". diff --git a/Session/Onboarding/Onboarding.swift b/Session/Onboarding/Onboarding.swift index aad20b279..f5d1fc564 100644 --- a/Session/Onboarding/Onboarding.swift +++ b/Session/Onboarding/Onboarding.swift @@ -28,7 +28,7 @@ enum Onboarding { switch self { case .register: - userDefaults[.hasViewedSeed] = false + GRDBStorage.shared.write { db in db[.hasViewedSeed] = false } // Set hasSyncedInitialConfiguration to true so that when we hit the // home screen a configuration sync is triggered (yes, the logic is a // bit weird). This is needed so that if the user registers and @@ -37,7 +37,7 @@ enum Onboarding { case .recover, .link: // No need to show it again if the user is restoring or linking - userDefaults[.hasViewedSeed] = true + GRDBStorage.shared.write { db in db[.hasViewedSeed] = true } userDefaults[.hasSyncedInitialConfiguration] = false } diff --git a/Session/Onboarding/SeedReminderView.swift b/Session/Onboarding/SeedReminderView.swift index ca165b034..f75e4e48f 100644 --- a/Session/Onboarding/SeedReminderView.swift +++ b/Session/Onboarding/SeedReminderView.swift @@ -1,5 +1,9 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -final class SeedReminderView : UIView { +import UIKit +import SessionUIKit + +final class SeedReminderView: UIView { private let hasContinueButton: Bool var title = NSAttributedString(string: "") { didSet { titleLabel.attributedText = title } } var subtitle = "" { didSet { subtitleLabel.text = subtitle } } diff --git a/Session/Onboarding/SeedVC.swift b/Session/Onboarding/SeedVC.swift index 3a3405ead..c519275a3 100644 --- a/Session/Onboarding/SeedVC.swift +++ b/Session/Onboarding/SeedVC.swift @@ -170,8 +170,8 @@ final class SeedVC: BaseVC { self.seedReminderView.subtitle = NSLocalizedString("view_seed_reminder_subtitle_3", comment: "") }, completion: nil) seedReminderView.setProgress(1, animated: true) - UserDefaults.standard[.hasViewedSeed] = true - NotificationCenter.default.post(name: .seedViewed, object: nil) + + GRDBStorage.shared.write { db in db[.hasViewedSeed] = true } } @objc private func copyMnemonic() { diff --git a/Session/Settings/SeedModal.swift b/Session/Settings/SeedModal.swift index 6146dced3..f62fd4252 100644 --- a/Session/Settings/SeedModal.swift +++ b/Session/Settings/SeedModal.swift @@ -71,9 +71,9 @@ final class SeedModal: Modal { stackView.pin(.top, to: .top, of: contentView, withInset: Values.largeSpacing) contentView.pin(.trailing, to: .trailing, of: stackView, withInset: Values.largeSpacing) contentView.pin(.bottom, to: .bottom, of: stackView, withInset: Values.largeSpacing) + // Mark seed as viewed - UserDefaults.standard[.hasViewedSeed] = true - NotificationCenter.default.post(name: .seedViewed, object: nil) + GRDBStorage.shared.write { db in db[.hasViewedSeed] = true } } // MARK: Interaction diff --git a/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift b/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift index e06e2fade..5660d060d 100644 --- a/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift +++ b/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift @@ -75,6 +75,7 @@ public enum SMKLegacy { internal static let soundsGlobalNotificationKey = "kOWSSoundsStorageGlobalNotificationKey" internal static let userDefaultsHasHiddenMessageRequests = "hasHiddenMessageRequests" + internal static let userDefaultsHasViewedSeedKey = "hasViewedSeed" // MARK: - DatabaseMigration diff --git a/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift b/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift index 5b4dcf4ad..345068b11 100644 --- a/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift @@ -335,8 +335,10 @@ enum _001_InitialSetupMigration: Migration { try db.create(table: ThreadTypingIndicator.self) { t in t.column(.threadId, .text) .primaryKey() - .references(SessionThread.self, onDelete: .cascade) // Delete if thread deleted + .references(SessionThread.self, onDelete: .cascade) // Delete if thread deleted t.column(.timestampMs, .integer).notNull() } + + GRDBStorage.shared.update(progress: 1, for: self, in: target) // In case this is the last migration } } diff --git a/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift b/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift index dac6eaebc..877693bf5 100644 --- a/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift +++ b/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift @@ -51,5 +51,7 @@ enum _002_SetupStandardJobs: Migration { behaviour: .recurringOnLaunch ).inserted(db) } + + GRDBStorage.shared.update(progress: 1, for: self, in: target) // In case this is the last migration } } diff --git a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift index 38d28b322..1d14576af 100644 --- a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -1295,9 +1295,14 @@ enum _003_YDBToGRDBMigration: Migration { db[.areLinkPreviewsEnabled] = (legacyPreferences[SMKLegacy.preferencesKeyAreLinkPreviewsEnabled] as? Bool == true) db[.hasHiddenMessageRequests] = CurrentAppContext().appUserDefaults() .bool(forKey: SMKLegacy.userDefaultsHasHiddenMessageRequests) + + // Note: The 'hasViewedSeed' was originally stored on standard user defaults + db[.hasViewedSeed] = UserDefaults.standard.bool(forKey: SMKLegacy.userDefaultsHasViewedSeedKey) db[.hasSavedThread] = (legacyPreferences[SMKLegacy.preferencesKeyHasSavedThreadKey] as? Bool == true) db[.hasSentAMessage] = (legacyPreferences[SMKLegacy.preferencesKeyHasSentAMessageKey] as? Bool == true) db[.isReadyForAppExtensions] = CurrentAppContext().appUserDefaults().bool(forKey: SMKLegacy.preferencesKeyIsReadyForAppExtensions) + + GRDBStorage.shared.update(progress: 1, for: self, in: target) // In case this is the last migration } // MARK: - Convenience diff --git a/SessionMessagingKit/Database/Models/Interaction.swift b/SessionMessagingKit/Database/Models/Interaction.swift index 3a3779c0d..12160586a 100644 --- a/SessionMessagingKit/Database/Models/Interaction.swift +++ b/SessionMessagingKit/Database/Models/Interaction.swift @@ -27,12 +27,13 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu /// Whenever using this `linkPreview` association make sure to filter the result using /// `.filter(literal: Interaction.linkPreviewFilterLiteral)` to ensure the correct LinkPreview is returned public static let linkPreview = hasOne(LinkPreview.self, using: LinkPreview.interactionForeignKey) - public static let linkPreviewFilterLiteral: SQL = { - let interaction: TypedTableAlias = TypedTableAlias() + public static func linkPreviewFilterLiteral( + timestampColumn: SQL = SQL(stringLiteral: Interaction.Columns.timestampMs.name) + ) -> SQL { let linkPreview: TypedTableAlias = TypedTableAlias() - return "(ROUND((\(interaction[.timestampMs]) / 1000 / 100000) - 0.5) * 100000) = \(linkPreview[.timestamp])" - }() + return "(ROUND((\(Interaction.self).\(timestampColumn) / 1000 / 100000) - 0.5) * 100000) = \(linkPreview[.timestamp])" + } public static let recipientStates = hasMany(RecipientState.self, using: RecipientState.interactionForeignKey) public typealias Columns = CodingKeys @@ -354,7 +355,7 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu .aliased(interactionAlias) .joining( required: Interaction.linkPreview - .filter(literal: Interaction.linkPreviewFilterLiteral) + .filter(literal: Interaction.linkPreviewFilterLiteral()) ) .fetchCount(db) let tmp = try linkPreview.fetchAll(db) diff --git a/SessionMessagingKit/Database/Models/Profile.swift b/SessionMessagingKit/Database/Models/Profile.swift index bd6cbf6d9..977eb4541 100644 --- a/SessionMessagingKit/Database/Models/Profile.swift +++ b/SessionMessagingKit/Database/Models/Profile.swift @@ -90,14 +90,8 @@ public struct Profile: Codable, Identifiable, Equatable, Hashable, FetchableReco } } - // Since it's possible this profile is currently being displayed, send notifications - // indicating that it has been updated - NotificationCenter.default.post(name: .profileUpdated, object: id) - - if id == getUserHexEncodedPublicKey(db) { - NotificationCenter.default.post(name: .localProfileDidChange, object: nil) - } - else { + // FIXME: Remove this once the OWSConversationSettingsViewController has been refactored and is observing DB changes + if id != getUserHexEncodedPublicKey(db) { let userInfo = [ Notification.Key.profileRecipientId.rawValue: id ] NotificationCenter.default.post(name: .otherUsersProfileDidChange, object: nil, userInfo: userInfo) } diff --git a/SessionMessagingKit/Database/Models/RecipientState.swift b/SessionMessagingKit/Database/Models/RecipientState.swift index 01bb0a1b0..9c29d2002 100644 --- a/SessionMessagingKit/Database/Models/RecipientState.swift +++ b/SessionMessagingKit/Database/Models/RecipientState.swift @@ -22,20 +22,34 @@ public struct RecipientState: Codable, Equatable, FetchableRecord, PersistableRe } public enum State: Int, Codable, Hashable, DatabaseValueConvertible { - case failed + /// These cases **MUST** remain in this order (even though having `failed` as `0` would be more logical) as the order + /// is optimised for the desired "interactionState" grouping behaviour we want which makes the query to retrieve the interaction + /// state run ~16 times than the alternate approach which required a sub-query (check git history to see the old approach at the + /// bottom of this file if desired) + /// + /// The expected behaviour of the grouped "interactionState" that both the `SessionThreadViewModel` and + /// `MessageViewModel` should use is `IFNULL(MIN("recipientState"."state"), 'sending')` (joining on the + /// `interaction.id` and `state != 'skipped'`): + /// - The 'skipped' state should be ignored entirely + /// - If there is no state (ie. interaction recipient records not yet created) then the interaction state should be 'sending' + /// - If there is a single 'sending' state then the interaction state should be 'sending' + /// - If there is a single 'failed' state and no 'sending' state then the interaction state should be 'failed' + /// - If there are neither 'sending' or 'failed' states then the interaction state should be 'sent' case sending + case failed case skipped case sent func message(hasAttachments: Bool, hasAtLeastOneReadReceipt: Bool) -> String { switch self { - case .failed: return "MESSAGE_STATUS_FAILED".localized() case .sending: guard hasAttachments else { return "MESSAGE_STATUS_SENDING".localized() } return "MESSAGE_STATUS_UPLOADING".localized() + + case .failed: return "MESSAGE_STATUS_FAILED".localized() case .sent: guard hasAtLeastOneReadReceipt else { @@ -117,28 +131,3 @@ public extension RecipientState { ) } } - -// MARK: - GRDB Queries - -public extension RecipientState { - static func selectInteractionState(tableLiteral: SQL, idColumnLiteral: SQL) -> SQL { - let recipientState: TypedTableAlias = TypedTableAlias() - - return """ - SELECT * FROM ( - SELECT - \(recipientState[.interactionId]), - \(recipientState[.state]), - \(recipientState[.mostRecentFailureText]) - FROM \(RecipientState.self) - 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 c2f517965..803fe7f22 100644 --- a/SessionMessagingKit/Database/Models/SessionThread.swift +++ b/SessionMessagingKit/Database/Models/SessionThread.swift @@ -185,17 +185,6 @@ public extension SessionThread { return existingThread } - static func messageRequestThreads(_ db: Database) -> QueryInterfaceRequest { - return SessionThread - .filter(Columns.shouldBeVisible == true) - .filter(Columns.variant == Variant.contact) - .filter(Columns.id != getUserHexEncodedPublicKey(db)) - .joining( - optional: contact - .filter(Contact.Columns.isApproved == false) - ) - } - func isMessageRequest(_ db: Database) -> Bool { return ( shouldBeVisible && @@ -209,23 +198,38 @@ public extension SessionThread { // MARK: - Convenience public extension SessionThread { + static func messageRequestsCountQuery(userPublicKey: String) -> SQLRequest { + let thread: TypedTableAlias = TypedTableAlias() + let contact: TypedTableAlias = TypedTableAlias() + + return """ + SELECT COUNT(\(thread[.id])) + FROM \(SessionThread.self) + LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id]) + WHERE ( + \(SessionThread.isMessageRequest(userPublicKey: userPublicKey)) + ) + """ + } + static func unreadMessageRequestsCountQuery(userPublicKey: String) -> SQLRequest { let thread: TypedTableAlias = TypedTableAlias() let contact: TypedTableAlias = TypedTableAlias() let interaction: TypedTableAlias = TypedTableAlias() - let unreadInteractionLiteral: SQL = SQL(stringLiteral: "unreadInteraction") - let interactionThreadIdColumnLiteral: SQL = SQL(stringLiteral: Interaction.Columns.threadId.name) - return """ SELECT COUNT(\(thread[.id])) FROM \(SessionThread.self) JOIN ( - SELECT \(interaction[.threadId]) + SELECT + \(interaction[.threadId]), + MIN(\(interaction[.wasRead])) AS \(SQL(stringLiteral: "\(Interaction.Columns.wasRead.name)")) FROM \(Interaction.self) - WHERE \(interaction[.wasRead]) = false GROUP BY \(interaction[.threadId]) - ) AS \(unreadInteractionLiteral) ON \(unreadInteractionLiteral).\(interactionThreadIdColumnLiteral) = \(thread[.id]) + ) AS \(Interaction.self) ON ( + \(interaction[.threadId]) = \(thread[.id]) AND + \(interaction[.wasRead]) = false + ) LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id]) WHERE ( \(SessionThread.isMessageRequest(userPublicKey: userPublicKey)) @@ -244,32 +248,13 @@ public extension SessionThread { return SQL( """ \(thread[.shouldBeVisible]) = true AND - \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND - \(SQL("\(thread[.id]) != \(userPublicKey)")) AND ( - /* Note: A '!= true' check doesn't work properly so we need to be explicit */ - \(contact[.isApproved]) IS NULL OR - \(contact[.isApproved]) = false - ) + \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND + \(SQL("\(thread[.id]) != \(userPublicKey)")) AND + IFNULL(\(contact[.isApproved]), false) = false """ ) } - /// This method can be used to filter a thread query to exclude messages requests - /// - /// **Note:** In order to use this filter you **MUST** have a `joining(required/optional:)` to the - /// `SessionThread.contact` association or it won't work - static func isNotMessageRequest(userPublicKey: String) -> SQLSpecificExpressible { - let contactAlias: TypedTableAlias = TypedTableAlias() - - return ( - SessionThread.Columns.shouldBeVisible == true && ( - SessionThread.Columns.variant != SessionThread.Variant.contact || - SessionThread.Columns.id == userPublicKey || // Note to self - contactAlias[.isApproved] == true - ) - ) - } - func isNoteToSelf(_ db: Database? = nil) -> Bool { return ( variant == .contact && diff --git a/SessionMessagingKit/Database/Notification+Contacts.swift b/SessionMessagingKit/Database/Notification+Contacts.swift index 857d88cb4..6679490cf 100644 --- a/SessionMessagingKit/Database/Notification+Contacts.swift +++ b/SessionMessagingKit/Database/Notification+Contacts.swift @@ -4,15 +4,11 @@ import SessionUtilitiesKit public extension Notification.Name { - static let profileUpdated = Notification.Name("profileUpdated") - static let localProfileDidChange = Notification.Name("localProfileDidChange") static let otherUsersProfileDidChange = Notification.Name("otherUsersProfileDidChange") } @objc public extension NSNotification { - @objc static let profileUpdated = Notification.Name.profileUpdated.rawValue as NSString - @objc static let localProfileDidChange = Notification.Name.localProfileDidChange.rawValue as NSString @objc static let otherUsersProfileDidChange = Notification.Name.otherUsersProfileDidChange.rawValue as NSString } diff --git a/SessionMessagingKit/Shared Models/MessageViewModel.swift b/SessionMessagingKit/Shared Models/MessageViewModel.swift index e7e5ea9e4..403f0913c 100644 --- a/SessionMessagingKit/Shared Models/MessageViewModel.swift +++ b/SessionMessagingKit/Shared Models/MessageViewModel.swift @@ -75,8 +75,8 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, public let state: RecipientState.State public let hasAtLeastOneReadReceipt: Bool public let mostRecentFailureText: String? - public let isTypingIndicator: Bool public let isSenderOpenGroupModerator: Bool + public let isTypingIndicator: Bool? public let profile: Profile? public let quote: Quote? public let quoteAttachment: Attachment? @@ -169,7 +169,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, isLast: Bool ) -> MessageViewModel { let cellType: CellType = { - guard !self.isTypingIndicator else { return .typingIndicator } + guard self.isTypingIndicator != true else { return .typingIndicator } guard self.variant != .standardIncomingDeleted else { return .textOnlyMessage } guard let attachment: Attachment = self.attachments?.first else { return .textOnlyMessage } @@ -208,7 +208,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, nickname: nil // Folded into 'authorName' within the Query ) let shouldShowDateOnThisModel: Bool = { - guard !self.isTypingIndicator else { return false } + guard self.isTypingIndicator != true else { return false } guard let prevModel: ViewModel = prevModel else { return true } return MessageViewModel.shouldShowDateBreak( @@ -218,7 +218,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, }() let shouldShowDateOnNextModel: Bool = { // Should be nothing after a typing indicator - guard !self.isTypingIndicator else { return false } + guard self.isTypingIndicator != true else { return false } guard let nextModel: ViewModel = nextModel else { return false } return MessageViewModel.shouldShowDateBreak( @@ -404,18 +404,21 @@ public extension MessageViewModel { // MARK: - Convenience Initialization public extension MessageViewModel { - public static let genericId: Int64 = -2 - public static let typingIndicatorId: Int64 = -2 + static let genericId: Int64 = -2 + static let typingIndicatorId: Int64 = -2 // Note: This init method is only used system-created cells or empty states - init(isTypingIndicator: Bool = false) { + init(isTypingIndicator: Bool? = nil) { self.threadVariant = .contact self.threadIsTrusted = false self.threadHasDisappearingMessagesEnabled = false // Interaction Info - let targetId: Int64 = (isTypingIndicator ? MessageViewModel.typingIndicatorId : MessageViewModel.genericId) + let targetId: Int64 = (isTypingIndicator == true ? + MessageViewModel.typingIndicatorId : + MessageViewModel.genericId + ) self.rowId = targetId self.id = targetId self.variant = .standardOutgoing @@ -513,7 +516,7 @@ public extension MessageViewModel { return { additionalFilters, limitSQL -> AdaptedFetchRequest> in let interaction: TypedTableAlias = TypedTableAlias() let thread: TypedTableAlias = TypedTableAlias() - let typingIndicator: TypedTableAlias = TypedTableAlias() + let recipientState: TypedTableAlias = TypedTableAlias() let contact: TypedTableAlias = TypedTableAlias() let disappearingMessagesConfig: TypedTableAlias = TypedTableAlias() let profile: TypedTableAlias = TypedTableAlias() @@ -522,7 +525,6 @@ public extension MessageViewModel { 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) @@ -543,7 +545,7 @@ public extension MessageViewModel { """ }() let finalLimitSQL: SQL = (limitSQL ?? SQL(stringLiteral: "")) - let numColumnsBeforeLinkedRecords: Int = 17 + let numColumnsBeforeLinkedRecords: Int = 16 let request: SQLRequest = """ SELECT \(thread[.variant]) AS \(ViewModel.threadVariantKey), @@ -563,11 +565,10 @@ public extension MessageViewModel { \(interaction[.expiresInSeconds]), -- Default to 'sending' assuming non-processed interaction when null - IFNULL(\(interactionStateTableLiteral).\(interactionStateStateColumnLiteral), \(SQL("\(RecipientState.State.sending)"))) AS \(ViewModel.stateKey), + IFNULL(MIN(\(recipientState[.state])), \(SQL("\(RecipientState.State.sending)"))) AS \(ViewModel.stateKey), (\(readReceiptTableLiteral).\(readReceiptReadTimestampMsColumnLiteral) IS NOT NULL) AS \(ViewModel.hasAtLeastOneReadReceiptKey), - \(interactionStateTableLiteral).\(interactionStateMostRecentFailureTextColumnLiteral) AS \(ViewModel.mostRecentFailureTextKey), - - (\(typingIndicator[.threadId]) IS NOT NULL) AS \(ViewModel.isTypingIndicatorKey), + \(recipientState[.mostRecentFailureText]) AS \(ViewModel.mostRecentFailureTextKey), + false AS \(ViewModel.isSenderOpenGroupModeratorKey), \(ViewModel.profileKey).*, @@ -587,7 +588,6 @@ public extension MessageViewModel { FROM \(Interaction.self) JOIN \(SessionThread.self) ON \(thread[.id]) = \(interaction[.threadId]) - LEFT JOIN \(ThreadTypingIndicator.self) ON \(typingIndicator[.threadId]) = \(thread[.id]) 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]) @@ -595,20 +595,20 @@ public extension MessageViewModel { 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) + \(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) ON ( + -- Ignore 'skipped' states + \(SQL("\(recipientState[.state]) != \(RecipientState.State.skipped)")) AND + \(recipientState[.interactionId]) = \(interaction[.id]) + ) LEFT JOIN \(RecipientState.self) AS \(readReceiptTableLiteral) ON ( \(readReceiptTableLiteral).\(readReceiptReadTimestampMsColumnLiteral) IS NOT NULL AND \(interaction[.id]) = \(readReceiptTableLiteral).\(interactionStateInteractionIdColumnLiteral) ) \(finalFilterSQL) + GROUP BY \(interaction[.id]) ORDER BY \(orderSQL) \(finalLimitSQL) """ diff --git a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift index 29c9e9361..8da43ce45 100644 --- a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift +++ b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift @@ -47,6 +47,7 @@ public struct SessionThreadViewModel: FetchableRecord, Decodable, Equatable, Has public static let interactionVariantKey: SQL = SQL(stringLiteral: CodingKeys.interactionVariant.stringValue) public static let interactionTimestampMsKey: SQL = SQL(stringLiteral: CodingKeys.interactionTimestampMs.stringValue) public static let interactionBodyKey: SQL = SQL(stringLiteral: CodingKeys.interactionBody.stringValue) + public static let interactionStateKey: SQL = SQL(stringLiteral: CodingKeys.interactionState.stringValue) public static let interactionIsOpenGroupInvitationKey: SQL = SQL(stringLiteral: CodingKeys.interactionIsOpenGroupInvitation.stringValue) public static let interactionAttachmentDescriptionInfoKey: SQL = SQL(stringLiteral: CodingKeys.interactionAttachmentDescriptionInfo.stringValue) public static let interactionAttachmentCountKey: SQL = SQL(stringLiteral: CodingKeys.interactionAttachmentCount.stringValue) @@ -262,27 +263,17 @@ public extension SessionThreadViewModel { let groupMember: TypedTableAlias = TypedTableAlias() let openGroup: TypedTableAlias = TypedTableAlias() let interaction: TypedTableAlias = TypedTableAlias() + let recipientState: TypedTableAlias = TypedTableAlias() let linkPreview: TypedTableAlias = TypedTableAlias() + let attachment: TypedTableAlias = TypedTableAlias() let interactionAttachment: TypedTableAlias = TypedTableAlias() + let profile: TypedTableAlias = TypedTableAlias() - let unreadCountTableLiteral: SQL = SQL(stringLiteral: "\(ViewModel.threadUnreadCountString)_table") - let unreadMentionCountTableLiteral: SQL = SQL(stringLiteral: "\(ViewModel.threadUnreadMentionCountString)_table") - let interactionThreadIdLiteral: SQL = SQL(stringLiteral: Interaction.Columns.threadId.name) let profileIdColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.id.name) - let profileNicknameColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.nickname.name) - let profileNameColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.name.name) - let authorProfileLiteral: SQL = SQL(stringLiteral: "authorProfile") - let attachmentIdColumnLiteral: SQL = SQL(stringLiteral: Attachment.Columns.id.name) - let attachmentVariantColumnLiteral: SQL = SQL(stringLiteral: Attachment.Columns.variant.name) - let attachmentContentTypeColumnLiteral: SQL = SQL(stringLiteral: Attachment.Columns.contentType.name) - let attachmentSourceFilenameColumnLiteral: SQL = SQL(stringLiteral: Attachment.Columns.sourceFilename.name) let firstInteractionAttachmentLiteral: SQL = SQL(stringLiteral: "firstInteractionAttachment") let interactionAttachmentAttachmentIdColumnLiteral: SQL = SQL(stringLiteral: InteractionAttachment.Columns.attachmentId.name) let interactionAttachmentInteractionIdColumnLiteral: SQL = SQL(stringLiteral: InteractionAttachment.Columns.interactionId.name) let interactionAttachmentAlbumIndexColumnLiteral: SQL = SQL(stringLiteral: InteractionAttachment.Columns.albumIndex.name) - let interactionStateTableLiteral: SQL = SQL(stringLiteral: "interactionState") - let interactionStateInteractionIdColumnLiteral: SQL = SQL(stringLiteral: RecipientState.Columns.interactionId.name) - let interactionStateStateColumnLiteral: SQL = SQL(stringLiteral: RecipientState.Columns.state.name) /// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before /// the `ViewModel.contactProfileKey` entry below otherwise the query will fail to @@ -291,7 +282,6 @@ public extension SessionThreadViewModel { /// Explicitly set default values for the fields ignored for search results let numColumnsBeforeProfiles: Int = 11 let numColumnsBetweenProfilesAndAttachmentInfo: Int = 10 // The attachment info columns will be combined - // TODO: Some testing around the subqueries in the joins to see if they impact performance ('Simulator1' device takes ~125ms to complete this query) let request: SQLRequest = """ SELECT @@ -306,8 +296,8 @@ public extension SessionThreadViewModel { \(thread[.onlyNotifyForMentions]) AS \(ViewModel.threadOnlyNotifyForMentionsKey), (\(typingIndicator[.threadId]) IS NOT NULL) AS \(ViewModel.threadContactIsTypingKey), - \(unreadCountTableLiteral).\(ViewModel.threadUnreadCountKey) AS \(ViewModel.threadUnreadCountKey), - \(unreadMentionCountTableLiteral).\(ViewModel.threadUnreadMentionCountKey) AS \(ViewModel.threadUnreadMentionCountKey), + \(Interaction.self).\(ViewModel.threadUnreadCountKey), + \(Interaction.self).\(ViewModel.threadUnreadMentionCountKey), \(ViewModel.contactProfileKey).*, \(ViewModel.closedGroupProfileFrontKey).*, @@ -318,59 +308,75 @@ public extension SessionThreadViewModel { \(openGroup[.name]) AS \(ViewModel.openGroupNameKey), \(openGroup[.imageData]) AS \(ViewModel.openGroupProfilePictureDataKey), - \(interaction[.id]) AS \(ViewModel.interactionIdKey), - \(interaction[.variant]) AS \(ViewModel.interactionVariantKey), - \(interaction[.timestampMs]) AS \(ViewModel.interactionTimestampMsKey), - \(interaction[.body]) AS \(ViewModel.interactionBodyKey), + \(Interaction.self).\(ViewModel.interactionIdKey), + \(Interaction.self).\(ViewModel.interactionVariantKey), + \(Interaction.self).\(ViewModel.interactionTimestampMsKey), + \(Interaction.self).\(ViewModel.interactionBodyKey), + -- Default to 'sending' assuming non-processed interaction when null - IFNULL(\(interactionStateTableLiteral).\(interactionStateStateColumnLiteral), \(SQL("\(RecipientState.State.sending)"))) AS \(interactionStateTableLiteral), + IFNULL(MIN(\(recipientState[.state])), \(SQL("\(RecipientState.State.sending)"))) AS \(ViewModel.interactionStateKey), (\(linkPreview[.url]) IS NOT NULL) AS \(ViewModel.interactionIsOpenGroupInvitationKey), - \(ViewModel.interactionAttachmentDescriptionInfoKey).\(attachmentIdColumnLiteral), - \(ViewModel.interactionAttachmentDescriptionInfoKey).\(attachmentVariantColumnLiteral), - \(ViewModel.interactionAttachmentDescriptionInfoKey).\(attachmentContentTypeColumnLiteral), - \(ViewModel.interactionAttachmentDescriptionInfoKey).\(attachmentSourceFilenameColumnLiteral), + + -- These 4 properties will be combined into 'Attachment.DescriptionInfo' + \(attachment[.id]), + \(attachment[.variant]), + \(attachment[.contentType]), + \(attachment[.sourceFilename]), COUNT(\(interactionAttachment[.interactionId])) AS \(ViewModel.interactionAttachmentCountKey), \(interaction[.authorId]), - IFNULL(\(authorProfileLiteral).\(profileNicknameColumnLiteral), \(authorProfileLiteral).\(profileNameColumnLiteral)) AS \(ViewModel.authorNameInternalKey), + IFNULL(\(profile[.nickname]), \(profile[.name])) AS \(ViewModel.authorNameInternalKey), \(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 *, MAX(\(interaction[.timestampMs])) + -- Fetch all interaction-specific data in a subquery to be more efficient + SELECT + \(interaction[.id]) AS \(ViewModel.interactionIdKey), + \(interaction[.threadId]), + \(interaction[.variant]) AS \(ViewModel.interactionVariantKey), + MAX(\(interaction[.timestampMs])) AS \(ViewModel.interactionTimestampMsKey), + \(interaction[.body]) AS \(ViewModel.interactionBodyKey), + \(interaction[.authorId]), + \(interaction[.linkPreviewUrl]), + + SUM(\(interaction[.wasRead]) = false) AS \(ViewModel.threadUnreadCountKey), + SUM(\(interaction[.wasRead]) = false AND \(interaction[.hasMention]) = true) AS \(ViewModel.threadUnreadMentionCountKey) + FROM \(Interaction.self) GROUP BY \(interaction[.threadId]) ) AS \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id]) - LEFT JOIN ( - SELECT - \(interaction[.threadId]), - COUNT(*) AS \(ViewModel.threadUnreadCountKey) - FROM \(Interaction.self) - WHERE \(interaction[.wasRead]) = false - 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 - ) - GROUP BY \(interaction[.threadId]) - ) AS \(unreadMentionCountTableLiteral) ON \(unreadMentionCountTableLiteral).\(interactionThreadIdLiteral) = \(thread[.id]) + + LEFT JOIN \(RecipientState.self) ON ( + -- Ignore 'skipped' states + \(SQL("\(recipientState[.state]) != \(RecipientState.State.skipped)")) AND + \(recipientState[.interactionId]) = \(Interaction.self).\(ViewModel.interactionIdKey) + ) + LEFT JOIN \(LinkPreview.self) ON ( + \(linkPreview[.url]) = \(interaction[.linkPreviewUrl]) AND + \(SQL("\(linkPreview[.variant]) = \(LinkPreview.Variant.openGroupInvitation)")) AND + \(Interaction.linkPreviewFilterLiteral(timestampColumn: ViewModel.interactionTimestampMsKey)) + ) + LEFT JOIN \(InteractionAttachment.self) AS \(firstInteractionAttachmentLiteral) ON ( + \(firstInteractionAttachmentLiteral).\(interactionAttachmentAlbumIndexColumnLiteral) = 0 AND + \(firstInteractionAttachmentLiteral).\(interactionAttachmentInteractionIdColumnLiteral) = \(Interaction.self).\(ViewModel.interactionIdKey) + ) + LEFT JOIN \(Attachment.self) ON \(attachment[.id]) = \(firstInteractionAttachmentLiteral).\(interactionAttachmentAttachmentIdColumnLiteral) + LEFT JOIN \(InteractionAttachment.self) ON \(interactionAttachment[.interactionId]) = \(Interaction.self).\(ViewModel.interactionIdKey) + LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(interaction[.authorId]) + + -- Thread naming & avatar content LEFT JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON \(ViewModel.contactProfileKey).\(profileIdColumnLiteral) = \(thread[.id]) + LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id]) LEFT JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id]) LEFT JOIN \(GroupMember.self) ON ( \(SQL("\(groupMember[.role]) = \(GroupMember.Role.admin)")) AND \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND \(SQL("\(groupMember[.profileId]) = \(userPublicKey)")) ) - LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id]) LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileFrontKey) ON ( \(ViewModel.closedGroupProfileFrontKey).\(profileIdColumnLiteral) = ( @@ -400,25 +406,6 @@ public extension SessionThreadViewModel { \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) IS NULL AND \(ViewModel.closedGroupProfileBackFallbackKey).\(profileIdColumnLiteral) = \(SQL("\(userPublicKey)")) ) - - LEFT JOIN \(Profile.self) AS \(authorProfileLiteral) ON \(authorProfileLiteral).\(profileIdColumnLiteral) = \(interaction[.authorId]) - LEFT JOIN \(LinkPreview.self) ON ( - \(linkPreview[.url]) = \(interaction[.linkPreviewUrl]) AND - \(SQL("\(linkPreview[.variant]) = \(LinkPreview.Variant.openGroupInvitation)")) AND - \(Interaction.linkPreviewFilterLiteral) - ) - LEFT JOIN \(InteractionAttachment.self) ON \(interactionAttachment[.interactionId]) = \(interaction[.id]) - LEFT JOIN \(InteractionAttachment.self) AS \(firstInteractionAttachmentLiteral) ON ( - \(firstInteractionAttachmentLiteral).\(interactionAttachmentAlbumIndexColumnLiteral) = 0 AND - \(firstInteractionAttachmentLiteral).\(interactionAttachmentInteractionIdColumnLiteral) = \(interaction[.id]) - ) - LEFT JOIN \(Attachment.self) AS \(ViewModel.interactionAttachmentDescriptionInfoKey) ON \(ViewModel.interactionAttachmentDescriptionInfoKey).\(attachmentIdColumnLiteral) = \(firstInteractionAttachmentLiteral).\(interactionAttachmentAttachmentIdColumnLiteral) - LEFT JOIN ( - \(RecipientState.selectInteractionState( - tableLiteral: interactionStateTableLiteral, - idColumnLiteral: interactionStateInteractionIdColumnLiteral - )) - ) AS \(interactionStateTableLiteral) ON \(interactionStateTableLiteral).\(interactionStateInteractionIdColumnLiteral) = \(interaction[.id]) WHERE ( \(filters) @@ -452,7 +439,6 @@ public extension SessionThreadViewModel { static func homeQuery(userPublicKey: String) -> AdaptedFetchRequest> { let thread: TypedTableAlias = TypedTableAlias() let contact: TypedTableAlias = TypedTableAlias() - let interaction: TypedTableAlias = TypedTableAlias() return baseQuery( userPublicKey: userPublicKey, @@ -465,11 +451,11 @@ public extension SessionThreadViewModel { ) AND ( -- Only show the 'Note to Self' thread if it has an interaction \(SQL("\(thread[.id]) != \(userPublicKey)")) OR - \(interaction[.id]) IS NOT NULL + \(Interaction.self).\(ViewModel.interactionIdKey) IS NOT NULL ) """, ordering: """ - \(thread[.isPinned]) DESC, IFNULL(\(interaction[.timestampMs]), \(thread[.creationDateTimestamp])) DESC + \(thread[.isPinned]) DESC, IFNULL(\(Interaction.self).\(ViewModel.interactionTimestampMsKey), \(thread[.creationDateTimestamp])) DESC """ ) } @@ -477,7 +463,6 @@ public extension SessionThreadViewModel { static func messageRequestsQuery(userPublicKey: String) -> AdaptedFetchRequest> { let thread: TypedTableAlias = TypedTableAlias() let contact: TypedTableAlias = TypedTableAlias() - let interaction: TypedTableAlias = TypedTableAlias() return baseQuery( userPublicKey: userPublicKey, @@ -493,7 +478,7 @@ public extension SessionThreadViewModel { ) """, ordering: """ - IFNULL(\(interaction[.timestampMs]), \(thread[.creationDateTimestamp])) DESC + IFNULL(\(Interaction.self).\(ViewModel.interactionTimestampMsKey), \(thread[.creationDateTimestamp])) DESC """ ) } @@ -510,8 +495,6 @@ public extension SessionThreadViewModel { 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) @@ -555,8 +538,8 @@ public extension SessionThreadViewModel { \(thread[.onlyNotifyForMentions]) AS \(ViewModel.threadOnlyNotifyForMentionsKey), \(thread[.messageDraft]) AS \(ViewModel.threadMessageDraftKey), - \(unreadCountTableLiteral).\(ViewModel.threadUnreadCountKey) AS \(ViewModel.threadUnreadCountKey), - \(unreadMentionCountTableLiteral).\(ViewModel.threadUnreadMentionCountKey) AS \(ViewModel.threadUnreadMentionCountKey), + \(Interaction.self).\(ViewModel.threadUnreadCountKey), + \(Interaction.self).\(ViewModel.threadUnreadMentionCountKey), \(firstUnreadInteractionTableLiteral).\(interactionIdLiteral) AS \(ViewModel.threadFirstUnreadInteractionIdKey), \(ViewModel.contactProfileKey).*, @@ -569,12 +552,25 @@ public extension SessionThreadViewModel { \(openGroup[.imageData]) AS \(ViewModel.openGroupProfilePictureDataKey), \(openGroup[.userCount]) AS \(ViewModel.openGroupUserCountKey), - \(interaction[.id]) AS \(ViewModel.interactionIdKey), + \(Interaction.self).\(ViewModel.interactionIdKey), \(SQL("\(userPublicKey)")) AS \(ViewModel.currentUserPublicKeyKey) FROM \(SessionThread.self) LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id]) + LEFT JOIN ( + -- Fetch all interaction-specific data in a subquery to be more efficient + SELECT + \(interaction[.id]) AS \(ViewModel.interactionIdKey), + \(interaction[.threadId]), + MAX(\(interaction[.timestampMs])), + + SUM(\(interaction[.wasRead]) = false) AS \(ViewModel.threadUnreadCountKey), + SUM(\(interaction[.wasRead]) = false AND \(interaction[.hasMention]) = true) AS \(ViewModel.threadUnreadMentionCountKey) + + FROM \(Interaction.self) + GROUP BY \(interaction[.threadId]) + ) AS \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id]) LEFT JOIN ( SELECT \(interaction[.id]), @@ -586,36 +582,9 @@ public extension SessionThreadViewModel { \(SQL("\(interaction[.threadId]) = \(threadId)")) ) ) AS \(firstUnreadInteractionTableLiteral) ON \(firstUnreadInteractionTableLiteral).\(interactionThreadIdLiteral) = \(thread[.id]) - LEFT JOIN ( - SELECT *, MAX(\(interaction[.timestampMs])) - FROM \(Interaction.self) - GROUP BY \(interaction[.threadId]) - ) AS \(Interaction.self) ON \(interaction[.threadId]) = \(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 \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id]) LEFT JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id]) LEFT JOIN \(GroupMember.self) ON ( \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND @@ -633,7 +602,6 @@ public extension SessionThreadViewModel { ) 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]) diff --git a/SessionMessagingKit/Utilities/Preferences.swift b/SessionMessagingKit/Utilities/Preferences.swift index 913b76f92..6badcaeae 100644 --- a/SessionMessagingKit/Utilities/Preferences.swift +++ b/SessionMessagingKit/Utilities/Preferences.swift @@ -44,6 +44,9 @@ public extension Setting.BoolKey { /// Controls whether the notification sound should play while the app is in the foreground static let playNotificationSoundInForeground: Setting.BoolKey = "playNotificationSoundInForeground" + /// A flag indicating whether the user has ever viewed their seed + static let hasViewedSeed: Setting.BoolKey = "hasViewedSeed" + /// A flag indicating whether the user has ever saved a thread static let hasSavedThread: Setting.BoolKey = "hasSavedThread" diff --git a/SessionNotificationServiceExtension/NSENotificationPresenter.swift b/SessionNotificationServiceExtension/NSENotificationPresenter.swift index cdc20df0c..8dd26e205 100644 --- a/SessionNotificationServiceExtension/NSENotificationPresenter.swift +++ b/SessionNotificationServiceExtension/NSENotificationPresenter.swift @@ -11,7 +11,8 @@ public class NSENotificationPresenter: NSObject, NotificationsProtocol { public func notifyUser(_ db: Database, for interaction: Interaction, in thread: SessionThread, isBackgroundPoll: Bool) { guard Date().timeIntervalSince1970 < (thread.mutedUntilTimestamp ?? 0) else { return } - let isMessageRequest = thread.isMessageRequest(db) + let userPublicKey: String = getUserHexEncodedPublicKey(db) + let isMessageRequest: Bool = thread.isMessageRequest(db) // If the thread is a message request and the user hasn't hidden message requests then we need // to check if this is the only message request thread (group threads can't be message requests @@ -19,8 +20,10 @@ public class NSENotificationPresenter: NSObject, NotificationsProtocol { // notification regardless of how many message requests there are) if thread.variant == .contact { if isMessageRequest && !db[.hasHiddenMessageRequests] { - let numMessageRequestThreads: Int? = try? SessionThread.messageRequestThreads(db) - .fetchCount(db) + let numMessageRequestThreads: Int? = (try? SessionThread + .messageRequestsCountQuery(userPublicKey: userPublicKey) + .fetchOne(db)) + .defaulting(to: 0) // Allow this to show a notification if there are no message requests (ie. this is the first one) guard (numMessageRequestThreads ?? 0) == 0 else { return } @@ -34,7 +37,6 @@ public class NSENotificationPresenter: NSObject, NotificationsProtocol { } let senderPublicKey: String = interaction.authorId - let userPublicKey: String = getUserHexEncodedPublicKey() guard senderPublicKey != userPublicKey else { // Ignore PNs for messages sent by the current user diff --git a/SessionSnodeKit/Database/Migrations/_001_InitialSetupMigration.swift b/SessionSnodeKit/Database/Migrations/_001_InitialSetupMigration.swift index f807c07b1..9bb714b0f 100644 --- a/SessionSnodeKit/Database/Migrations/_001_InitialSetupMigration.swift +++ b/SessionSnodeKit/Database/Migrations/_001_InitialSetupMigration.swift @@ -51,5 +51,7 @@ enum _001_InitialSetupMigration: Migration { t.uniqueKey([.key, .hash]) } + + GRDBStorage.shared.update(progress: 1, for: self, in: target) // In case this is the last migration } } diff --git a/SessionSnodeKit/Database/Migrations/_002_SetupStandardJobs.swift b/SessionSnodeKit/Database/Migrations/_002_SetupStandardJobs.swift index ad4037ecf..94d722639 100644 --- a/SessionSnodeKit/Database/Migrations/_002_SetupStandardJobs.swift +++ b/SessionSnodeKit/Database/Migrations/_002_SetupStandardJobs.swift @@ -20,5 +20,7 @@ enum _002_SetupStandardJobs: Migration { shouldBlockFirstRunEachSession: true ).inserted(db) } + + GRDBStorage.shared.update(progress: 1, for: self, in: target) // In case this is the last migration } } diff --git a/SessionSnodeKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionSnodeKit/Database/Migrations/_003_YDBToGRDBMigration.swift index 6f2242741..65f982d4d 100644 --- a/SessionSnodeKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionSnodeKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -202,5 +202,7 @@ enum _003_YDBToGRDBMigration: Migration { ).inserted(db) } } + + GRDBStorage.shared.update(progress: 1, for: self, in: target) // In case this is the last migration } } diff --git a/SessionUtilitiesKit/Database/Migrations/_001_InitialSetupMigration.swift b/SessionUtilitiesKit/Database/Migrations/_001_InitialSetupMigration.swift index e1e19ea0c..d37a5b598 100644 --- a/SessionUtilitiesKit/Database/Migrations/_001_InitialSetupMigration.swift +++ b/SessionUtilitiesKit/Database/Migrations/_001_InitialSetupMigration.swift @@ -64,5 +64,7 @@ enum _001_InitialSetupMigration: Migration { .primaryKey() t.column(.value, .blob).notNull() } + + GRDBStorage.shared.update(progress: 1, for: self, in: target) // In case this is the last migration } } diff --git a/SessionUtilitiesKit/Database/Migrations/_002_SetupStandardJobs.swift b/SessionUtilitiesKit/Database/Migrations/_002_SetupStandardJobs.swift index 61fb1aed3..f08cb1ce9 100644 --- a/SessionUtilitiesKit/Database/Migrations/_002_SetupStandardJobs.swift +++ b/SessionUtilitiesKit/Database/Migrations/_002_SetupStandardJobs.swift @@ -20,5 +20,7 @@ enum _002_SetupStandardJobs: Migration { behaviour: .recurringOnLaunch ).inserted(db) } + + GRDBStorage.shared.update(progress: 1, for: self, in: target) // In case this is the last migration } } diff --git a/SessionUtilitiesKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionUtilitiesKit/Database/Migrations/_003_YDBToGRDBMigration.swift index d70216cc3..fc87b4c85 100644 --- a/SessionUtilitiesKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionUtilitiesKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -112,5 +112,7 @@ enum _003_YDBToGRDBMigration: Migration { data: userX25519KeyPair.publicKey ).insert(db) } + + GRDBStorage.shared.update(progress: 1, for: self, in: target) // In case this is the last migration } } diff --git a/SessionUtilitiesKit/General/SNUserDefaults.swift b/SessionUtilitiesKit/General/SNUserDefaults.swift index 6e09a5711..b95910a43 100644 --- a/SessionUtilitiesKit/General/SNUserDefaults.swift +++ b/SessionUtilitiesKit/General/SNUserDefaults.swift @@ -4,7 +4,6 @@ public enum SNUserDefaults { public enum Bool : Swift.String { case hasSyncedInitialConfiguration = "hasSyncedConfiguration" - case hasViewedSeed case hasSeenLinkPreviewSuggestion case isUsingFullAPNs case wasUnlinked diff --git a/SignalUtilitiesKit/Utilities/Notification+Loki.swift b/SignalUtilitiesKit/Utilities/Notification+Loki.swift index 6383bc077..46d08fadf 100644 --- a/SignalUtilitiesKit/Utilities/Notification+Loki.swift +++ b/SignalUtilitiesKit/Utilities/Notification+Loki.swift @@ -3,12 +3,9 @@ import Foundation public extension Notification.Name { // State changes - static let blockedContactsUpdated = Notification.Name("blockedContactsUpdated") static let contactOnlineStatusChanged = Notification.Name("contactOnlineStatusChanged") static let threadDeleted = Notification.Name("threadDeleted") static let threadSessionRestoreDevicesChanged = Notification.Name("threadSessionRestoreDevicesChanged") - // Onboarding - static let seedViewed = Notification.Name("seedViewed") // Interaction static let dataNukeRequested = Notification.Name("dataNukeRequested") } @@ -16,12 +13,9 @@ public extension Notification.Name { @objc public extension NSNotification { // State changes - @objc static let blockedContactsUpdated = Notification.Name.blockedContactsUpdated.rawValue as NSString @objc static let contactOnlineStatusChanged = Notification.Name.contactOnlineStatusChanged.rawValue as NSString @objc static let threadDeleted = Notification.Name.threadDeleted.rawValue as NSString @objc static let threadSessionRestoreDevicesChanged = Notification.Name.threadSessionRestoreDevicesChanged.rawValue as NSString - // Onboarding - @objc static let seedViewed = Notification.Name.seedViewed.rawValue as NSString // Interaction @objc static let dataNukeRequested = Notification.Name.dataNukeRequested.rawValue as NSString }