diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index e77691409..8af44cbd2 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -302,8 +302,6 @@ C32C5DD2256DD9E5003C73A2 /* LRUCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDAFD255A580600E217F9 /* LRUCache.swift */; }; C32C5DDB256DD9FF003C73A2 /* ContentProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB68255A580F00E217F9 /* ContentProxy.swift */; }; C32C5E0C256DDAFA003C73A2 /* NSRegularExpression+SSK.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDA7A255A57FB00E217F9 /* NSRegularExpression+SSK.swift */; }; - C32C5FBB256E0206003C73A2 /* OWSBackgroundTask.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDC1B255A581F00E217F9 /* OWSBackgroundTask.m */; }; - C32C5FC4256E0209003C73A2 /* OWSBackgroundTask.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB38255A580B00E217F9 /* OWSBackgroundTask.h */; settings = {ATTRIBUTES = (Public, ); }; }; C32C600F256E07F5003C73A2 /* NSUserDefaults+OWS.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB77255A581000E217F9 /* NSUserDefaults+OWS.m */; }; C32C6018256E07F9003C73A2 /* NSUserDefaults+OWS.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB51255A580D00E217F9 /* NSUserDefaults+OWS.h */; settings = {ATTRIBUTES = (Public, ); }; }; C33100082558FF6D00070591 /* NewConversationButtonSet.swift in Sources */ = {isa = PBXBuildFile; fileRef = B83F2B85240C7B8F000A54AB /* NewConversationButtonSet.swift */; }; @@ -648,6 +646,7 @@ FD37EA0F28AB3330003AE748 /* _006_FixHiddenModAdminSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37EA0E28AB3330003AE748 /* _006_FixHiddenModAdminSupport.swift */; }; FD37EA1128AB34B3003AE748 /* TypedTableAlteration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37EA1028AB34B3003AE748 /* TypedTableAlteration.swift */; }; FD37EA1528AB42CB003AE748 /* IdentitySpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37EA1428AB42CB003AE748 /* IdentitySpec.swift */; }; + FD37EA1B28ACB51F003AE748 /* _007_HomeQueryOptimisationIndexes.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37EA1A28ACB51F003AE748 /* _007_HomeQueryOptimisationIndexes.swift */; }; FD3AABE928306BBD00E5099A /* ThreadPickerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3AABE828306BBD00E5099A /* ThreadPickerViewModel.swift */; }; FD3C905C27E3FBEF00CD579F /* BatchRequestInfoSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C905B27E3FBEF00CD579F /* BatchRequestInfoSpec.swift */; }; FD3C906027E410F700CD579F /* FileUploadResponseSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C905F27E410F700CD579F /* FileUploadResponseSpec.swift */; }; @@ -661,6 +660,8 @@ FD3E0C84283B5835002A425C /* SessionThreadViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3E0C83283B5835002A425C /* SessionThreadViewModel.swift */; }; FD42F9A8285064B800A0C77D /* PushNotificationAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBDE255A581900E217F9 /* PushNotificationAPI.swift */; }; FD4B200E283492210034334B /* InsetLockableTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD4B200D283492210034334B /* InsetLockableTableView.swift */; }; + FD52090028AF6153006098F6 /* OWSBackgroundTask.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDC1B255A581F00E217F9 /* OWSBackgroundTask.m */; }; + FD52090128AF61BA006098F6 /* OWSBackgroundTask.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB38255A580B00E217F9 /* OWSBackgroundTask.h */; settings = {ATTRIBUTES = (Public, ); }; }; FD5C72F7284F0E560029977D /* MessageReceiver+ReadReceipts.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5C72F6284F0E560029977D /* MessageReceiver+ReadReceipts.swift */; }; FD5C72F9284F0E880029977D /* MessageReceiver+TypingIndicators.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5C72F8284F0E880029977D /* MessageReceiver+TypingIndicators.swift */; }; FD5C72FB284F0EA10029977D /* MessageReceiver+DataExtractionNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5C72FA284F0EA10029977D /* MessageReceiver+DataExtractionNotification.swift */; }; @@ -780,7 +781,6 @@ FDCDB8DE2810F73B00352A0C /* Differentiable+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDCDB8DD2810F73B00352A0C /* Differentiable+Utilities.swift */; }; FDCDB8E02811007F00352A0C /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDCDB8DF2811007F00352A0C /* HomeViewModel.swift */; }; FDCDB8E42817819600352A0C /* (null) in Sources */ = {isa = PBXBuildFile; }; - FDCDB8F12817ABE600352A0C /* Optional+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDCDB8F02817ABE600352A0C /* Optional+Utilities.swift */; }; FDD2506E283711D600198BDA /* DifferenceKit+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDD2506D283711D600198BDA /* DifferenceKit+Utilities.swift */; }; FDD250702837199200198BDA /* GarbageCollectionJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDD2506F2837199200198BDA /* GarbageCollectionJob.swift */; }; FDD250722837234B00198BDA /* MediaGalleryNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDD250712837234B00198BDA /* MediaGalleryNavigationController.swift */; }; @@ -1691,6 +1691,7 @@ FD37EA0E28AB3330003AE748 /* _006_FixHiddenModAdminSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _006_FixHiddenModAdminSupport.swift; sourceTree = ""; }; FD37EA1028AB34B3003AE748 /* TypedTableAlteration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypedTableAlteration.swift; sourceTree = ""; }; FD37EA1428AB42CB003AE748 /* IdentitySpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentitySpec.swift; sourceTree = ""; }; + FD37EA1A28ACB51F003AE748 /* _007_HomeQueryOptimisationIndexes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _007_HomeQueryOptimisationIndexes.swift; sourceTree = ""; }; FD3AABE828306BBD00E5099A /* ThreadPickerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadPickerViewModel.swift; sourceTree = ""; }; FD3C905B27E3FBEF00CD579F /* BatchRequestInfoSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchRequestInfoSpec.swift; sourceTree = ""; }; FD3C905F27E410F700CD579F /* FileUploadResponseSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileUploadResponseSpec.swift; sourceTree = ""; }; @@ -1816,7 +1817,6 @@ FDC6D75F2862B3F600B04575 /* Dependencies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dependencies.swift; sourceTree = ""; }; FDCDB8DD2810F73B00352A0C /* Differentiable+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Differentiable+Utilities.swift"; sourceTree = ""; }; FDCDB8DF2811007F00352A0C /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = ""; }; - FDCDB8F02817ABE600352A0C /* Optional+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Optional+Utilities.swift"; sourceTree = ""; }; FDD2506D283711D600198BDA /* DifferenceKit+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DifferenceKit+Utilities.swift"; sourceTree = ""; }; FDD2506F2837199200198BDA /* GarbageCollectionJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GarbageCollectionJob.swift; sourceTree = ""; }; FDD250712837234B00198BDA /* MediaGalleryNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaGalleryNavigationController.swift; sourceTree = ""; }; @@ -3057,8 +3057,6 @@ C38EF2F5255B6DBC007E1867 /* OWSAudioPlayer.h */, C38EF2F7255B6DBC007E1867 /* OWSAudioPlayer.m */, C38EF281255B6D84007E1867 /* OWSAudioSession.swift */, - C33FDB38255A580B00E217F9 /* OWSBackgroundTask.h */, - C33FDC1B255A581F00E217F9 /* OWSBackgroundTask.m */, FDF0B75D280AAF35004C14C5 /* Preferences.swift */, C38EF2FB255B6DBD007E1867 /* OWSWindowManager.h */, C38EF306255B6DBE007E1867 /* OWSWindowManager.m */, @@ -3126,7 +3124,6 @@ B8A582AE258C65D000AFD84C /* Networking */, B8A582AD258C655E00AFD84C /* PromiseKit */, FD09796527F6B0A800936362 /* Utilities */, - FDCDB8EF2817ABCE00352A0C /* Utilities */, C3D9E43025676D3D0040E4F3 /* Configuration.swift */, ); path = SessionUtilitiesKit; @@ -3426,6 +3423,8 @@ FD09797127FAA2F500936362 /* Optional+Utilities.swift */, FD09797C27FBDB2000936362 /* Notification+Utilities.swift */, FDF222082818D2B0000A4995 /* NSAttributedString+Utilities.swift */, + C33FDB38255A580B00E217F9 /* OWSBackgroundTask.h */, + C33FDC1B255A581F00E217F9 /* OWSBackgroundTask.m */, ); path = Utilities; sourceTree = ""; @@ -3464,6 +3463,7 @@ FDF40CDD2897A1BC006A0CC4 /* _004_RemoveLegacyYDB.swift */, FD37EA0C28AB2A45003AE748 /* _005_FixDeletedMessageReadState.swift */, FD37EA0E28AB3330003AE748 /* _006_FixHiddenModAdminSupport.swift */, + FD37EA1A28ACB51F003AE748 /* _007_HomeQueryOptimisationIndexes.swift */, ); path = Migrations; sourceTree = ""; @@ -3884,14 +3884,6 @@ path = Models; sourceTree = ""; }; - FDCDB8EF2817ABCE00352A0C /* Utilities */ = { - isa = PBXGroup; - children = ( - FDCDB8F02817ABE600352A0C /* Optional+Utilities.swift */, - ); - path = Utilities; - sourceTree = ""; - }; FDE7214E287E50D50093DF33 /* Scripts */ = { isa = PBXGroup; children = ( @@ -4006,6 +3998,7 @@ C3C2A67D255388CC00C340D1 /* SessionUtilitiesKit.h in Headers */, C32C6018256E07F9003C73A2 /* NSUserDefaults+OWS.h in Headers */, B8856D8D256F1502001CE70E /* UIView+OWS.h in Headers */, + FD52090128AF61BA006098F6 /* OWSBackgroundTask.h in Headers */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4015,7 +4008,6 @@ files = ( C3C2A6F425539DE700C340D1 /* SessionMessagingKit.h in Headers */, C32C5C46256DCBB2003C73A2 /* AppReadiness.h in Headers */, - C32C5FC4256E0209003C73A2 /* OWSBackgroundTask.h in Headers */, FD716E732850647900C96BF4 /* NSData+messagePadding.h in Headers */, B8856D72256F1421001CE70E /* OWSWindowManager.h in Headers */, B8856CF7256F105E001CE70E /* OWSAudioPlayer.h in Headers */, @@ -5022,7 +5014,6 @@ FDA8EB10280F8238002B68E5 /* Codable+Utilities.swift in Sources */, C352A36D2557858E00338F3E /* NSTimer+Proxying.m in Sources */, FD09797B27FBB25900936362 /* Updatable.swift in Sources */, - FDCDB8F12817ABE600352A0C /* Optional+Utilities.swift in Sources */, 7B7CB192271508AD0079FF93 /* CallRingTonePlayer.swift in Sources */, C3C2ABD22553C6C900C340D1 /* Data+SecureRandom.swift in Sources */, FD848B8B283DC509000E298B /* PagedDatabaseObserver.swift in Sources */, @@ -5068,6 +5059,7 @@ FD17D7B827F51ECA00122BE0 /* Migration.swift in Sources */, FD7728982849E8110018502F /* UITableView+ReusableView.swift in Sources */, 7B0EFDEE274F598600FFAAE7 /* TimestampUtils.swift in Sources */, + FD52090028AF6153006098F6 /* OWSBackgroundTask.m in Sources */, C32C5DDB256DD9FF003C73A2 /* ContentProxy.swift in Sources */, C3A71F892558BA9F0043A11F /* Mnemonic.swift in Sources */, B8F5F58325EC94A6003BF8D4 /* Collection+Subscripting.swift in Sources */, @@ -5114,6 +5106,7 @@ files = ( FD86585828507B24008B6CF9 /* NSData+messagePadding.m in Sources */, FD245C52285065D500B966DD /* SignalAttachment.swift in Sources */, + FD37EA1B28ACB51F003AE748 /* _007_HomeQueryOptimisationIndexes.swift in Sources */, B8856D08256F10F1001CE70E /* DeviceSleepManager.swift in Sources */, C3471F4C25553AB000297E91 /* MessageReceiver+Decryption.swift in Sources */, FD245C672850665E00B966DD /* AttachmentDownloadJob.swift in Sources */, @@ -5241,7 +5234,6 @@ FD245C5C2850660A00B966DD /* ConfigurationMessage.swift in Sources */, FD5C7301284F0F7A0029977D /* MessageReceiver+UnsendRequests.swift in Sources */, C3C2A75F2553A3C500C340D1 /* VisibleMessage+LinkPreview.swift in Sources */, - C32C5FBB256E0206003C73A2 /* OWSBackgroundTask.m in Sources */, FD245C642850664F00B966DD /* Threading.swift in Sources */, FD848B8D283E0B26000E298B /* MessageInputTypes.swift in Sources */, C32C5C3D256DCBAF003C73A2 /* AppReadiness.m in Sources */, diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index b06c8d0b2..a8491c901 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -688,6 +688,17 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers let wasLoadingMore: Bool = self.isLoadingMore let wasOffsetCloseToBottom: Bool = self.isCloseToBottom let numItemsInUpdatedData: [Int] = updatedData.map { $0.elements.count } + let didSwapAllContent: Bool = (updatedData + .first(where: { $0.model == .messages })? + .elements + .contains(where: { + $0.id == self.viewModel.interactionData + .first(where: { $0.model == .messages })? + .elements + .first? + .id + })) + .defaulting(to: false) let itemChangeInfo: ItemChangeInfo? = { guard isInsert, @@ -720,7 +731,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers ) }() - guard !isInsert || wasLoadingMore || itemChangeInfo?.isInsertAtTop == true else { + guard !isInsert || itemChangeInfo?.isInsertAtTop == true else { self.viewModel.updateInteractionData(updatedData) self.tableView.reloadData() @@ -729,16 +740,27 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers if let focusedInteractionId: Int64 = self.focusedInteractionId { // If we had a focusedInteractionId then scroll to it (and hide the search // result bar loading indicator) - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { [weak self] in + let delay: DispatchTime = (didSwapAllContent ? + .now() : + (.now() + .milliseconds(100)) + ) + + DispatchQueue.main.asyncAfter(deadline: delay) { [weak self] in self?.searchController.resultsBar.stopLoading() self?.scrollToInteractionIfNeeded( with: focusedInteractionId, isAnimated: true, highlight: (self?.shouldHighlightNextScrollToInteraction == true) ) + + if wasLoadingMore { + // Complete page loading + self?.isLoadingMore = false + self?.autoLoadNextPageIfNeeded() + } } } - else if wasOffsetCloseToBottom { + else if wasOffsetCloseToBottom && !wasLoadingMore { // Scroll to the bottom if an interaction was just inserted and we either // just sent a message or are close enough to the bottom (wait a tiny fraction // to avoid buggy animation behaviour) @@ -746,6 +768,11 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers self?.scrollToBottom(isAnimated: true) } } + else if wasLoadingMore { + // Complete page loading + self.isLoadingMore = false + self.autoLoadNextPageIfNeeded() + } return } @@ -755,7 +782,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers /// /// Unfortunately the UITableView also does some weird things when updating (where it won't have updated it's internal data until /// after it performs the next layout); the below code checks a condition on layout and if it passes it calls a closure - if let itemChangeInfo: ItemChangeInfo = itemChangeInfo, (itemChangeInfo.isInsertAtTop || wasLoadingMore) { + if let itemChangeInfo: ItemChangeInfo = itemChangeInfo, itemChangeInfo.isInsertAtTop { let oldCellHeight: CGFloat = self.tableView.rectForRow(at: itemChangeInfo.oldVisibleIndexPath).height // The the user triggered the 'scrollToTop' animation (by tapping in the nav bar) then we @@ -789,7 +816,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers .rectForRow(at: itemChangeInfo.visibleIndexPath) .height let heightDiff: CGFloat = (oldCellHeight - (newTargetHeight ?? oldCellHeight)) - + self?.tableView.contentOffset.y += (calculatedRowHeights - heightDiff) } @@ -805,13 +832,36 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers ) } } - + // Complete page loading self?.isLoadingMore = false self?.autoLoadNextPageIfNeeded() } ) } + else if wasLoadingMore { + if let focusedInteractionId: Int64 = self.focusedInteractionId { + DispatchQueue.main.async { [weak self] in + // If we had a focusedInteractionId then scroll to it (and hide the search + // result bar loading indicator) + self?.searchController.resultsBar.stopLoading() + self?.scrollToInteractionIfNeeded( + with: focusedInteractionId, + isAnimated: true, + highlight: (self?.shouldHighlightNextScrollToInteraction == true) + ) + + // Complete page loading + self?.isLoadingMore = false + self?.autoLoadNextPageIfNeeded() + } + } + else { + // Complete page loading + self.isLoadingMore = false + self.autoLoadNextPageIfNeeded() + } + } self.tableView.reload( using: changeset, @@ -837,13 +887,13 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers // the screen will scroll to the bottom instead of the first unread message if let focusedInteractionId: Int64 = self.viewModel.focusedInteractionId { self.scrollToInteractionIfNeeded(with: focusedInteractionId, isAnimated: false, highlight: true) - self.unreadCountView.alpha = self.scrollButton.alpha } else { self.scrollToBottom(isAnimated: false) } self.scrollButton.alpha = self.getScrollButtonOpacity() + self.unreadCountView.alpha = self.scrollButton.alpha self.hasPerformedInitialScroll = true // Now that the data has loaded we need to check if either of the "load more" sections are @@ -1018,6 +1068,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers let scrollButtonOpacity: CGFloat = (self?.getScrollButtonOpacity() ?? 0) self?.scrollButton.alpha = scrollButtonOpacity + self?.unreadCountView.alpha = scrollButtonOpacity self?.view.setNeedsLayout() self?.view.layoutIfNeeded() @@ -1225,6 +1276,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers self.scrollToInteractionIfNeeded( with: lastInteractionId, position: .bottom, + isJumpingToLastInteraction: true, isAnimated: true ) return @@ -1283,7 +1335,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers let contentOffsetY = tableView.contentOffset.y let x = (lastPageTop - ConversationVC.bottomInset - contentOffsetY).clamp(0, .greatestFiniteMagnitude) let a = 1 / (ConversationVC.scrollButtonFullVisibilityThreshold - ConversationVC.scrollButtonNoVisibilityThreshold) - return a * x + return max(0, min(1, a * x)) } // MARK: - Search @@ -1394,6 +1446,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers func scrollToInteractionIfNeeded( with interactionId: Int64, position: UITableView.ScrollPosition = .middle, + isJumpingToLastInteraction: Bool = false, isAnimated: Bool = true, highlight: Bool = false ) { @@ -1417,10 +1470,18 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers self.searchController.resultsBar.startLoading() DispatchQueue.global(qos: .default).async { [weak self] in - self?.viewModel.pagedDataObserver?.load(.untilInclusive( - id: interactionId, - padding: 5 - )) + if isJumpingToLastInteraction { + self?.viewModel.pagedDataObserver?.load(.jumpTo( + id: interactionId, + paddingForInclusive: 5 + )) + } + else { + self?.viewModel.pagedDataObserver?.load(.untilInclusive( + id: interactionId, + padding: 5 + )) + } } return } diff --git a/Session/Home/HomeVC.swift b/Session/Home/HomeVC.swift index f45a9ece4..2f00bc37c 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -214,9 +214,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve } // Onion request path countries cache - DispatchQueue.global(qos: .utility).sync { - let _ = IP2Country.shared.populateCacheIfNeeded() - } + IP2Country.shared.populateCacheIfNeededAsync() } override func viewWillAppear(_ animated: Bool) { diff --git a/Session/Home/HomeViewModel.swift b/Session/Home/HomeViewModel.swift index a6c99443b..3b20abebe 100644 --- a/Session/Home/HomeViewModel.swift +++ b/Session/Home/HomeViewModel.swift @@ -43,8 +43,7 @@ public class HomeViewModel { // MARK: - Initialization init() { - self.state = Storage.shared.read { db in try HomeViewModel.retrieveState(db) } - .defaulting(to: State()) + self.state = State() self.pagedDataObserver = nil // Note: Since this references self we need to finish initializing before setting it, we @@ -139,14 +138,14 @@ public class HomeViewModel { }() ) ], - /// **Note:** This `optimisedJoinSQL` value includes the required minimum joins needed for the query + /// **Note:** This `optimisedJoinSQL` value includes the required minimum joins needed for the query but differs + /// from the JOINs that are actually used for performance reasons as the basic logic can be simpler for where it's used joinSQL: SessionThreadViewModel.optimisedJoinSQL, filterSQL: SessionThreadViewModel.homeFilterSQL(userPublicKey: userPublicKey), groupSQL: SessionThreadViewModel.groupSQL, orderSQL: SessionThreadViewModel.homeOrderSQL, dataQuery: SessionThreadViewModel.baseQuery( userPublicKey: userPublicKey, - filterSQL: SessionThreadViewModel.homeFilterSQL(userPublicKey: userPublicKey), groupSQL: SessionThreadViewModel.groupSQL, orderSQL: SessionThreadViewModel.homeOrderSQL ), @@ -194,8 +193,9 @@ public class HomeViewModel { let hasHiddenMessageRequests: Bool = db[.hasHiddenMessageRequests] let userProfile: Profile = Profile.fetchOrCreateCurrentUser(db) let unreadMessageRequestThreadCount: Int = try SessionThread - .unreadMessageRequestsThreadIdQuery(userPublicKey: userProfile.id) - .fetchCount(db) + .unreadMessageRequestsCountQuery(userPublicKey: userProfile.id) + .fetchOne(db) + .defaulting(to: 0) return State( showViewedSeedBanner: !hasViewedSeed, @@ -219,7 +219,8 @@ public class HomeViewModel { else { return } /// **MUST** have the same logic as in the 'PagedDataObserver.onChangeUnsorted' above - let currentData: [SessionThreadViewModel] = self.threadData.flatMap { $0.elements } + let currentData: [SessionThreadViewModel] = (self.unobservedThreadDataChanges ?? self.threadData) + .flatMap { $0.elements } let updatedThreadData: [SectionModel] = self.process(data: currentData, for: currentPageInfo) guard let onThreadChange: (([SectionModel]) -> ()) = self.onThreadChange else { diff --git a/Session/Home/Message Requests/MessageRequestsViewModel.swift b/Session/Home/Message Requests/MessageRequestsViewModel.swift index c19ff8538..e3dadb79c 100644 --- a/Session/Home/Message Requests/MessageRequestsViewModel.swift +++ b/Session/Home/Message Requests/MessageRequestsViewModel.swift @@ -86,14 +86,14 @@ public class MessageRequestsViewModel { }() ) ], - /// **Note:** This `optimisedJoinSQL` value includes the required minimum joins needed for the query + /// **Note:** This `optimisedJoinSQL` value includes the required minimum joins needed for the query but differs + /// from the JOINs that are actually used for performance reasons as the basic logic can be simpler for where it's used joinSQL: SessionThreadViewModel.optimisedJoinSQL, filterSQL: SessionThreadViewModel.messageRequestsFilterSQL(userPublicKey: userPublicKey), groupSQL: SessionThreadViewModel.groupSQL, orderSQL: SessionThreadViewModel.messageRequetsOrderSQL, dataQuery: SessionThreadViewModel.baseQuery( userPublicKey: userPublicKey, - filterSQL: SessionThreadViewModel.messageRequestsFilterSQL(userPublicKey: userPublicKey), groupSQL: SessionThreadViewModel.groupSQL, orderSQL: SessionThreadViewModel.messageRequetsOrderSQL ), diff --git a/Session/Media Viewing & Editing/MediaGalleryViewModel.swift b/Session/Media Viewing & Editing/MediaGalleryViewModel.swift index cc425d2d3..bad7fc350 100644 --- a/Session/Media Viewing & Editing/MediaGalleryViewModel.swift +++ b/Session/Media Viewing & Editing/MediaGalleryViewModel.swift @@ -367,7 +367,7 @@ public class MediaGalleryViewModel { .removeDuplicates() } - @discardableResult public func loadAndCacheAlbumData(for interactionId: Int64) -> [Item] { + @discardableResult public func loadAndCacheAlbumData(for interactionId: Int64, in threadId: String) -> [Item] { typealias AlbumInfo = (albumData: [Item], interactionIdBefore: Int64?, interactionIdAfter: Int64?) // Note: It's possible we already have cached album data for this interaction @@ -394,13 +394,19 @@ public class MediaGalleryViewModel { let itemBefore: Item? = try Item .baseQuery( orderSQL: Item.galleryReverseOrderSQL, - customFilters: SQL("\(interaction[.timestampMs]) > \(albumTimestampMs)") + customFilters: SQL(""" + \(interaction[.timestampMs]) > \(albumTimestampMs) AND + \(interaction[.threadId]) = \(threadId) + """) ) .fetchOne(db) let itemAfter: Item? = try Item .baseQuery( orderSQL: Item.galleryOrderSQL, - customFilters: SQL("\(interaction[.timestampMs]) < \(albumTimestampMs)") + customFilters: SQL(""" + \(interaction[.timestampMs]) < \(albumTimestampMs) AND + \(interaction[.threadId]) = \(threadId) + """) ) .fetchOne(db) @@ -505,7 +511,7 @@ public class MediaGalleryViewModel { threadVariant: threadVariant, isPagedData: false ) - viewModel.loadAndCacheAlbumData(for: interactionId) + viewModel.loadAndCacheAlbumData(for: interactionId, in: threadId) viewModel.replaceAlbumObservation(toObservationFor: interactionId) guard diff --git a/Session/Media Viewing & Editing/MediaPageViewController.swift b/Session/Media Viewing & Editing/MediaPageViewController.swift index 7b9f96349..ec290ea7e 100644 --- a/Session/Media Viewing & Editing/MediaPageViewController.swift +++ b/Session/Media Viewing & Editing/MediaPageViewController.swift @@ -681,10 +681,15 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou } // Then check if there is an interaction before the current album interaction - guard let interactionIdAfter: Int64 = self.viewModel.interactionIdAfter[interactionId] else { return nil } + guard let interactionIdAfter: Int64 = self.viewModel.interactionIdAfter[interactionId] else { + return nil + } // Cache and retrieve the new album items - let newAlbumItems: [MediaGalleryViewModel.Item] = viewModel.loadAndCacheAlbumData(for: interactionIdAfter) + let newAlbumItems: [MediaGalleryViewModel.Item] = viewModel.loadAndCacheAlbumData( + for: interactionIdAfter, + in: self.viewModel.threadId + ) guard !newAlbumItems.isEmpty, @@ -723,10 +728,15 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou } // Then check if there is an interaction before the current album interaction - guard let interactionIdBefore: Int64 = self.viewModel.interactionIdBefore[interactionId] else { return nil } + guard let interactionIdBefore: Int64 = self.viewModel.interactionIdBefore[interactionId] else { + return nil + } // Cache and retrieve the new album items - let newAlbumItems: [MediaGalleryViewModel.Item] = viewModel.loadAndCacheAlbumData(for: interactionIdBefore) + let newAlbumItems: [MediaGalleryViewModel.Item] = viewModel.loadAndCacheAlbumData( + for: interactionIdBefore, + in: self.viewModel.threadId + ) guard !newAlbumItems.isEmpty, diff --git a/Session/Meta/AppDelegate.swift b/Session/Meta/AppDelegate.swift index 3bbff0213..79db24db8 100644 --- a/Session/Meta/AppDelegate.swift +++ b/Session/Meta/AppDelegate.swift @@ -133,10 +133,11 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD // NOTE: Fix an edge case where user taps on the callkit notification // but answers the call on another device stopPollers(shouldStopUserPoller: !self.hasIncomingCallWaiting()) - JobRunner.stopAndClearPendingJobs() - // Suspend database - NotificationCenter.default.post(name: Database.suspendNotification, object: self) + // Stop all jobs except for message sending and when completed suspend the database + JobRunner.stopAndClearPendingJobs(exceptForVariant: .messageSend) { + NotificationCenter.default.post(name: Database.suspendNotification, object: self) + } } func applicationDidReceiveMemoryWarning(_ application: UIApplication) { diff --git a/Session/Meta/Signal-Bridging-Header.h b/Session/Meta/Signal-Bridging-Header.h index c9b488573..bd11856ea 100644 --- a/Session/Meta/Signal-Bridging-Header.h +++ b/Session/Meta/Signal-Bridging-Header.h @@ -44,7 +44,6 @@ #import #import #import -#import #import #import #import diff --git a/Session/Notifications/AppNotifications.swift b/Session/Notifications/AppNotifications.swift index e894f5ad6..3d3f62fbf 100644 --- a/Session/Notifications/AppNotifications.swift +++ b/Session/Notifications/AppNotifications.swift @@ -82,10 +82,6 @@ extension AppNotificationAction { } } -// Delay notification of incoming messages when it's a background polling to -// avoid too many notifications fired at the same time -let kNotificationDelayForBackgroumdPoll: TimeInterval = 5 - let kAudioNotificationsThrottleCount = 2 let kAudioNotificationsThrottleInterval: TimeInterval = 5 @@ -93,14 +89,48 @@ protocol NotificationPresenterAdaptee: AnyObject { func registerNotificationSettings() -> Promise - func notify(category: AppNotificationCategory, title: String?, body: String, userInfo: [AnyHashable: Any], sound: Preferences.Sound?) - func notify(category: AppNotificationCategory, title: String?, body: String, userInfo: [AnyHashable: Any], sound: Preferences.Sound?, replacingIdentifier: String?) + func notify( + category: AppNotificationCategory, + title: String?, + body: String, + userInfo: [AnyHashable: Any], + previewType: Preferences.NotificationPreviewType, + sound: Preferences.Sound?, + threadVariant: SessionThread.Variant, + threadName: String, + replacingIdentifier: String? + ) func cancelNotifications(threadId: String) func cancelNotifications(identifiers: [String]) func clearAllNotifications() } +extension NotificationPresenterAdaptee { + func notify( + category: AppNotificationCategory, + title: String?, + body: String, + userInfo: [AnyHashable: Any], + previewType: Preferences.NotificationPreviewType, + sound: Preferences.Sound?, + threadVariant: SessionThread.Variant, + threadName: String + ) { + notify( + category: category, + title: title, + body: body, + userInfo: userInfo, + previewType: previewType, + sound: sound, + threadVariant: threadVariant, + threadName: threadName, + replacingIdentifier: nil + ) + } +} + @objc(OWSNotificationPresenter) public class NotificationPresenter: NSObject, NotificationsProtocol { @@ -141,7 +171,7 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { return adaptee.registerNotificationSettings() } - public func notifyUser(_ db: Database, for interaction: Interaction, in thread: SessionThread, isBackgroundPoll: Bool) { + public func notifyUser(_ db: Database, for interaction: Interaction, in thread: SessionThread) { let isMessageRequest: Bool = thread.isMessageRequest(db, includeNonVisible: true) // Ensure we should be showing a notification for the thread @@ -149,7 +179,10 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { return } - let identifier: String = interaction.notificationIdentifier(isBackgroundPoll: isBackgroundPoll) + // Try to group notifications for interactions from open groups + let identifier: String = interaction.notificationIdentifier( + shouldGroupMessagesForThread: (thread.variant == .openGroup) + ) // While batch processing, some of the necessary changes have not been commited. let rawMessageText = interaction.previewText(db) @@ -166,6 +199,18 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { let senderName = Profile.displayName(db, id: interaction.authorId, threadVariant: thread.variant) let previewType: Preferences.NotificationPreviewType = db[.preferencesNotificationPreviewType] .defaulting(to: .nameAndPreview) + let groupName: String = SessionThread.displayName( + threadId: thread.id, + variant: thread.variant, + closedGroupName: try? thread.closedGroup + .select(.name) + .asRequest(of: String.self) + .fetchOne(db), + openGroupName: try? thread.openGroup + .select(.name) + .asRequest(of: String.self) + .fetchOne(db) + ) switch previewType { case .noNameNoPreview: @@ -177,26 +222,10 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { notificationTitle = (isMessageRequest ? "Session" : senderName) case .closedGroup, .openGroup: - let groupName: String = SessionThread - .displayName( - threadId: thread.id, - variant: thread.variant, - closedGroupName: try? thread.closedGroup - .select(.name) - .asRequest(of: String.self) - .fetchOne(db), - openGroupName: try? thread.openGroup - .select(.name) - .asRequest(of: String.self) - .fetchOne(db) - ) - - notificationTitle = (isBackgroundPoll ? groupName : - String( - format: NotificationStrings.incomingGroupMessageTitleFormat, - senderName, - groupName - ) + notificationTitle = String( + format: NotificationStrings.incomingGroupMessageTitleFormat, + senderName, + groupName ) } } @@ -243,9 +272,12 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { self.adaptee.notify( category: category, title: notificationTitle, - body: notificationBody ?? "", + body: (notificationBody ?? ""), userInfo: userInfo, + previewType: previewType, sound: sound, + threadVariant: thread.variant, + threadName: groupName, replacingIdentifier: identifier ) } @@ -268,23 +300,26 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { guard messageInfo.state == .missed || messageInfo.state == .permissionDenied else { return } let category = AppNotificationCategory.errorMessage - + let previewType: Preferences.NotificationPreviewType = db[.preferencesNotificationPreviewType] + .defaulting(to: .nameAndPreview) + let userInfo = [ AppNotificationUserInfoKey.threadId: thread.id ] - let notificationTitle = interaction.previewText(db) + let notificationTitle: String = interaction.previewText(db) + let threadName: String = SessionThread.displayName( + threadId: thread.id, + variant: thread.variant, + closedGroupName: nil, // Not supported + openGroupName: nil // Not supported + ) var notificationBody: String? if messageInfo.state == .permissionDenied { notificationBody = String( format: "modal_call_missed_tips_explanation".localized(), - SessionThread.displayName( - threadId: thread.id, - variant: thread.variant, - closedGroupName: nil, // Not supported - openGroupName: nil // Not supported - ) + threadName ) } @@ -294,9 +329,12 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { self.adaptee.notify( category: category, title: notificationTitle, - body: notificationBody ?? "", + body: (notificationBody ?? ""), userInfo: userInfo, + previewType: previewType, sound: sound, + threadVariant: thread.variant, + threadName: threadName, replacingIdentifier: UUID().uuidString ) } @@ -306,24 +344,24 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { let notificationTitle: String? let previewType: Preferences.NotificationPreviewType = db[.preferencesNotificationPreviewType] .defaulting(to: .nameAndPreview) + let threadName: String = SessionThread.displayName( + threadId: thread.id, + variant: thread.variant, + closedGroupName: try? thread.closedGroup + .select(.name) + .asRequest(of: String.self) + .fetchOne(db), + openGroupName: try? thread.openGroup + .select(.name) + .asRequest(of: String.self) + .fetchOne(db), + isNoteToSelf: (thread.isNoteToSelf(db) == true), + profile: try? Profile.fetchOne(db, id: thread.id) + ) switch previewType { case .noNameNoPreview: notificationTitle = nil - case .nameNoPreview, .nameAndPreview: - notificationTitle = SessionThread.displayName( - threadId: thread.id, - variant: thread.variant, - closedGroupName: try? thread.closedGroup - .select(.name) - .asRequest(of: String.self) - .fetchOne(db), - openGroupName: try? thread.openGroup - .select(.name) - .asRequest(of: String.self) - .fetchOne(db), - isNoteToSelf: (thread.isNoteToSelf(db) == true), - profile: try? Profile.fetchOne(db, id: thread.id) - ) + case .nameNoPreview, .nameAndPreview: notificationTitle = threadName } let notificationBody = NotificationStrings.failedToSendBody @@ -340,7 +378,10 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { title: notificationTitle, body: notificationBody, userInfo: userInfo, - sound: sound + previewType: previewType, + sound: sound, + threadVariant: thread.variant, + threadName: threadName ) } } diff --git a/Session/Notifications/UserNotificationsAdaptee.swift b/Session/Notifications/UserNotificationsAdaptee.swift index c7fd88113..c6da6323b 100644 --- a/Session/Notifications/UserNotificationsAdaptee.swift +++ b/Session/Notifications/UserNotificationsAdaptee.swift @@ -57,8 +57,9 @@ class UserNotificationPresenterAdaptee: NSObject, UNUserNotificationCenterDelega override init() { self.notificationCenter = UNUserNotificationCenter.current() + super.init() - notificationCenter.delegate = self + SwiftSingletons.register(self) } } @@ -86,29 +87,37 @@ extension UserNotificationPresenterAdaptee: NotificationPresenterAdaptee { } } - func notify(category: AppNotificationCategory, title: String?, body: String, userInfo: [AnyHashable: Any], sound: Preferences.Sound?) { - AssertIsOnMainThread() - notify(category: category, title: title, body: body, userInfo: userInfo, sound: sound, replacingIdentifier: nil) - } - - func notify(category: AppNotificationCategory, title: String?, body: String, userInfo: [AnyHashable: Any], sound: Preferences.Sound?, replacingIdentifier: String?) { + func notify( + category: AppNotificationCategory, + title: String?, + body: String, + userInfo: [AnyHashable: Any], + previewType: Preferences.NotificationPreviewType, + sound: Preferences.Sound?, + threadVariant: SessionThread.Variant, + threadName: String, + replacingIdentifier: String? + ) { AssertIsOnMainThread() + let threadIdentifier: String? = (userInfo[AppNotificationUserInfoKey.threadId] as? String) let content = UNMutableNotificationContent() content.categoryIdentifier = category.identifier content.userInfo = userInfo - let isReplacingNotification = replacingIdentifier != nil - var isBackgroudPoll = false - if let threadIdentifier = userInfo[AppNotificationUserInfoKey.threadId] as? String { - content.threadIdentifier = threadIdentifier - isBackgroudPoll = replacingIdentifier == threadIdentifier - } + content.threadIdentifier = (threadIdentifier ?? content.threadIdentifier) + + let shouldGroupNotification: Bool = ( + threadVariant == .openGroup && + replacingIdentifier == threadIdentifier + ) let isAppActive = UIApplication.shared.applicationState == .active if let sound = sound, sound != .none { content.sound = sound.notificationSound(isQuiet: isAppActive) } - let notificationIdentifier = isReplacingNotification ? replacingIdentifier! : UUID().uuidString + let notificationIdentifier: String = (replacingIdentifier ?? UUID().uuidString) + let isReplacingNotification: Bool = (notifications[notificationIdentifier] != nil) + var trigger: UNNotificationTrigger? if shouldPresentNotification(category: category, userInfo: userInfo) { if let displayableTitle = title?.filterForDisplay { @@ -117,30 +126,50 @@ extension UserNotificationPresenterAdaptee: NotificationPresenterAdaptee { if let displayableBody = body.filterForDisplay { content.body = displayableBody } - } else { + + if shouldGroupNotification { + trigger = UNTimeIntervalNotificationTrigger( + timeInterval: Notifications.delayForGroupedNotifications, + repeats: false + ) + + let numberExistingNotifications: Int? = notifications[notificationIdentifier]? + .content + .userInfo[AppNotificationUserInfoKey.threadNotificationCounter] + .asType(Int.self) + var numberOfNotifications: Int = (numberExistingNotifications ?? 1) + + if numberExistingNotifications != nil { + numberOfNotifications += 1 // Add one for the current notification + + content.title = (previewType == .noNameNoPreview ? + content.title : + threadName + ) + content.body = String( + format: NotificationStrings.incomingCollapsedMessagesBody, + "\(numberOfNotifications)" + ) + } + + content.userInfo[AppNotificationUserInfoKey.threadNotificationCounter] = numberOfNotifications + } + } + else { // Play sound and vibrate, but without a `body` no banner will show. Logger.debug("supressing notification body") } - - let trigger: UNNotificationTrigger? - if isBackgroudPoll { - trigger = UNTimeIntervalNotificationTrigger(timeInterval: kNotificationDelayForBackgroumdPoll, repeats: false) - let numberOfNotifications: Int - if let lastRequest = notifications[notificationIdentifier], let counter = lastRequest.content.userInfo[AppNotificationUserInfoKey.threadNotificationCounter] as? Int { - numberOfNotifications = counter + 1 - content.body = String(format: NotificationStrings.incomingCollapsedMessagesBody, "\(numberOfNotifications)") - } else { - numberOfNotifications = 1 - } - content.userInfo[AppNotificationUserInfoKey.threadNotificationCounter] = numberOfNotifications - } else { - trigger = nil - } - let request = UNNotificationRequest(identifier: notificationIdentifier, content: content, trigger: trigger) + let request = UNNotificationRequest( + identifier: notificationIdentifier, + content: content, + trigger: trigger + ) Logger.debug("presenting notification with identifier: \(notificationIdentifier)") + if isReplacingNotification { cancelNotifications(identifiers: [notificationIdentifier]) } + notificationCenter.add(request) notifications[notificationIdentifier] = request } @@ -196,7 +225,7 @@ extension UserNotificationPresenterAdaptee: NotificationPresenterAdaptee { guard let conversationViewController = UIApplication.shared.frontmostViewController as? ConversationVC else { return true } - + /// Show notifications for any **other** threads return (conversationViewController.viewModel.threadData.threadId != notificationThreadId) } diff --git a/Session/Shared/FullConversationCell.swift b/Session/Shared/FullConversationCell.swift index 14faf6618..8739de5c5 100644 --- a/Session/Shared/FullConversationCell.swift +++ b/Session/Shared/FullConversationCell.swift @@ -164,12 +164,14 @@ public final class FullConversationCell: UITableViewCell { // Unread count view unreadCountView.addSubview(unreadCountLabel) + unreadCountLabel.setCompressionResistanceHigh() unreadCountLabel.pin([ VerticalEdge.top, VerticalEdge.bottom ], to: unreadCountView) unreadCountView.pin(.leading, to: .leading, of: unreadCountLabel, withInset: -4) unreadCountView.pin(.trailing, to: .trailing, of: unreadCountLabel, withInset: 4) // Has mention view hasMentionView.addSubview(hasMentionLabel) + hasMentionLabel.setCompressionResistanceHigh() hasMentionLabel.pin(to: hasMentionView) // Label stack view diff --git a/Session/Utilities/BackgroundPoller.swift b/Session/Utilities/BackgroundPoller.swift index 3ae9d0a03..faef01712 100644 --- a/Session/Utilities/BackgroundPoller.swift +++ b/Session/Utilities/BackgroundPoller.swift @@ -34,7 +34,7 @@ public final class BackgroundPoller { poller.stop() return poller.poll( - isBackgroundPoll: true, + calledFromBackgroundPoller: true, isBackgroundPollerValid: { BackgroundPoller.isValid }, isPostCapabilitiesRetry: false ) @@ -82,7 +82,7 @@ public final class BackgroundPoller { groupPublicKey, on: DispatchQueue.main, maxRetryCount: 0, - isBackgroundPoll: true, + calledFromBackgroundPoller: true, isBackgroundPollValid: { BackgroundPoller.isValid } ) } @@ -134,7 +134,7 @@ public final class BackgroundPoller { threadId: threadId, details: MessageReceiveJob.Details( messages: threadMessages.map { $0.messageInfo }, - isBackgroundPoll: true + calledFromBackgroundPoller: true ) ) diff --git a/Session/Utilities/IP2Country.swift b/Session/Utilities/IP2Country.swift index 052331a3d..a1f13ebab 100644 --- a/Session/Utilities/IP2Country.swift +++ b/Session/Utilities/IP2Country.swift @@ -50,8 +50,8 @@ final class IP2Country { @objc func populateCacheIfNeededAsync() { // This has to be sync since the `countryNamesCache` dict doesn't like async access - IP2Country.workQueue.sync { - let _ = self.populateCacheIfNeeded() + IP2Country.workQueue.sync { [weak self] in + _ = self?.populateCacheIfNeeded() } } diff --git a/SessionMessagingKit/Configuration.swift b/SessionMessagingKit/Configuration.swift index 2230a04de..9aaba1842 100644 --- a/SessionMessagingKit/Configuration.swift +++ b/SessionMessagingKit/Configuration.swift @@ -18,7 +18,8 @@ public enum SNMessagingKit { // Just to make the external API nice ], [ _005_FixDeletedMessageReadState.self, - _006_FixHiddenModAdminSupport.self + _006_FixHiddenModAdminSupport.self, + _007_HomeQueryOptimisationIndexes.self ] ] ) diff --git a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift index b748da16d..df7ddce57 100644 --- a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -1253,7 +1253,7 @@ enum _003_YDBToGRDBMigration: Migration { threadId: processedMessage.threadId, details: MessageReceiveJob.Details( messages: [processedMessage.messageInfo], - isBackgroundPoll: legacyJob.isBackgroundPoll + calledFromBackgroundPoller: legacyJob.isBackgroundPoll ) )?.inserted(db) } diff --git a/SessionMessagingKit/Database/Migrations/_007_HomeQueryOptimisationIndexes.swift b/SessionMessagingKit/Database/Migrations/_007_HomeQueryOptimisationIndexes.swift new file mode 100644 index 000000000..b468098f7 --- /dev/null +++ b/SessionMessagingKit/Database/Migrations/_007_HomeQueryOptimisationIndexes.swift @@ -0,0 +1,37 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +/// This migration adds an index to the interaction table in order to improve the performance of retrieving the number of unread interactions +enum _007_HomeQueryOptimisationIndexes: Migration { + static let target: TargetMigrations.Identifier = .messagingKit + static let identifier: String = "HomeQueryOptimisationIndexes" + static let needsConfigSync: Bool = false + static let minExpectedRunDuration: TimeInterval = 0.1 + + static func migrate(_ db: Database) throws { + try db.create( + index: "interaction_on_wasRead_and_hasMention_and_threadId", + on: Interaction.databaseTableName, + columns: [ + Interaction.Columns.wasRead.name, + Interaction.Columns.hasMention.name, + Interaction.Columns.threadId.name + ] + ) + + try db.create( + index: "interaction_on_threadId_and_timestampMs_and_variant", + on: Interaction.databaseTableName, + columns: [ + Interaction.Columns.threadId.name, + Interaction.Columns.timestampMs.name, + Interaction.Columns.variant.name + ] + ) + + Storage.update(progress: 1, for: self, in: target) // In case this is the last migration + } +} diff --git a/SessionMessagingKit/Database/Models/Interaction.swift b/SessionMessagingKit/Database/Models/Interaction.swift index d94708b0d..641d33d75 100644 --- a/SessionMessagingKit/Database/Models/Interaction.swift +++ b/SessionMessagingKit/Database/Models/Interaction.swift @@ -262,7 +262,7 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu self.body = body self.timestampMs = timestampMs self.receivedAtTimestampMs = receivedAtTimestampMs - self.wasRead = (wasRead && variant.canBeUnread) + self.wasRead = (wasRead || !variant.canBeUnread) self.hasMention = hasMention self.expiresInSeconds = expiresInSeconds self.expiresStartedAtMs = expiresStartedAtMs @@ -304,7 +304,7 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu default: return timestampMs } }() - self.wasRead = (wasRead && variant.canBeUnread) + self.wasRead = (wasRead || !variant.canBeUnread) self.hasMention = hasMention self.expiresInSeconds = expiresInSeconds self.expiresStartedAtMs = expiresStartedAtMs @@ -409,7 +409,7 @@ public extension Interaction { body: (body ?? self.body), timestampMs: (timestampMs ?? self.timestampMs), receivedAtTimestampMs: self.receivedAtTimestampMs, - wasRead: (wasRead ?? self.wasRead), + wasRead: ((wasRead ?? self.wasRead) || !self.variant.canBeUnread), hasMention: (hasMention ?? self.hasMention), expiresInSeconds: (expiresInSeconds ?? self.expiresInSeconds), expiresStartedAtMs: (expiresStartedAtMs ?? self.expiresStartedAtMs), @@ -453,6 +453,23 @@ public extension Interaction { ) ) + // Clear out any notifications for the interactions we mark as read + Environment.shared?.notificationsManager.wrappedValue?.cancelNotifications( + identifiers: interactionIds + .map { interactionId in + Interaction.notificationIdentifier( + for: interactionId, + threadId: threadId, + shouldGroupMessagesForThread: false + ) + } + .appending(Interaction.notificationIdentifier( + for: 0, + threadId: threadId, + shouldGroupMessagesForThread: true + )) + ) + // If we want to send read receipts then try to add the 'SendReadReceiptsJob' if trySendReadReceipt { JobRunner.upsert( @@ -573,18 +590,27 @@ public extension Interaction { var notificationIdentifiers: [String] { [ - notificationIdentifier(isBackgroundPoll: true), - notificationIdentifier(isBackgroundPoll: false) + notificationIdentifier(shouldGroupMessagesForThread: true), + notificationIdentifier(shouldGroupMessagesForThread: false) ] } // MARK: - Functions - func notificationIdentifier(isBackgroundPoll: Bool) -> String { + func notificationIdentifier(shouldGroupMessagesForThread: Bool) -> String { // When the app is in the background we want the notifications to be grouped to prevent spam - guard isBackgroundPoll else { return threadId } + return Interaction.notificationIdentifier( + for: (id ?? 0), + threadId: threadId, + shouldGroupMessagesForThread: shouldGroupMessagesForThread + ) + } + + fileprivate static func notificationIdentifier(for id: Int64, threadId: String, shouldGroupMessagesForThread: Bool) -> String { + // When the app is in the background we want the notifications to be grouped to prevent spam + guard !shouldGroupMessagesForThread else { return threadId } - return "\(threadId)-\(id ?? 0)" + return "\(threadId)-\(id)" } func markingAsDeleted() -> Interaction { @@ -598,7 +624,7 @@ public extension Interaction { body: nil, timestampMs: timestampMs, receivedAtTimestampMs: receivedAtTimestampMs, - wasRead: (wasRead && Variant.standardIncomingDeleted.canBeUnread), + wasRead: (wasRead || !Variant.standardIncomingDeleted.canBeUnread), hasMention: hasMention, expiresInSeconds: expiresInSeconds, expiresStartedAtMs: expiresStartedAtMs, diff --git a/SessionMessagingKit/Database/Models/SessionThread.swift b/SessionMessagingKit/Database/Models/SessionThread.swift index 452ab8c49..86c8c8629 100644 --- a/SessionMessagingKit/Database/Models/SessionThread.swift +++ b/SessionMessagingKit/Database/Models/SessionThread.swift @@ -202,23 +202,24 @@ public extension SessionThread { """ } - static func unreadMessageRequestsThreadIdQuery(userPublicKey: String, includeNonVisible: Bool = false) -> SQLRequest { + static func unreadMessageRequestsCountQuery(userPublicKey: String, includeNonVisible: Bool = false) -> SQLRequest { let thread: TypedTableAlias = TypedTableAlias() let interaction: TypedTableAlias = TypedTableAlias() let contact: TypedTableAlias = TypedTableAlias() return """ - SELECT \(thread[.id]) - FROM \(SessionThread.self) - JOIN \(Interaction.self) ON ( - \(interaction[.threadId]) = \(thread[.id]) AND - \(interaction[.wasRead]) = false + SELECT COUNT(DISTINCT id) FROM ( + SELECT \(thread[.id]) AS id + FROM \(SessionThread.self) + JOIN \(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, includeNonVisible: includeNonVisible)) + ) ) - LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id]) - WHERE ( - \(SessionThread.isMessageRequest(userPublicKey: userPublicKey, includeNonVisible: includeNonVisible)) - ) - GROUP BY \(thread[.id]) """ } @@ -276,8 +277,8 @@ public extension SessionThread { // all the other message request threads have been read if !hasHiddenMessageRequests { let numUnreadMessageRequestThreads: Int = (try? SessionThread - .unreadMessageRequestsThreadIdQuery(userPublicKey: userPublicKey, includeNonVisible: true) - .fetchCount(db)) + .unreadMessageRequestsCountQuery(userPublicKey: userPublicKey, includeNonVisible: true) + .fetchOne(db)) .defaulting(to: 1) guard numUnreadMessageRequestThreads == 1 else { return false } diff --git a/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift b/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift index 6822f1fe0..582c5aae1 100644 --- a/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift +++ b/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift @@ -37,8 +37,7 @@ public enum MessageReceiveJob: JobExecutor { db, message: messageInfo.message, associatedWithProto: try SNProtoContent.parseData(messageInfo.serializedProtoData), - openGroupId: nil, - isBackgroundPoll: details.isBackgroundPoll + openGroupId: nil ) } catch { @@ -76,7 +75,7 @@ public enum MessageReceiveJob: JobExecutor { .with( details: Details( messages: remainingMessagesToProcess, - isBackgroundPoll: details.isBackgroundPoll + calledFromBackgroundPoller: details.calledFromBackgroundPoller ) ) .defaulting(to: job) @@ -164,14 +163,18 @@ extension MessageReceiveJob { } public let messages: [MessageInfo] - public let isBackgroundPoll: Bool + private let isBackgroundPoll: Bool + + // Renamed variable for clarity (and didn't want to migrate old MessageReceiveJob + // values so didn't rename the original) + public var calledFromBackgroundPoller: Bool { isBackgroundPoll } public init( messages: [MessageInfo], - isBackgroundPoll: Bool + calledFromBackgroundPoller: Bool ) { self.messages = messages - self.isBackgroundPoll = isBackgroundPoll + self.isBackgroundPoll = calledFromBackgroundPoller } } } diff --git a/SessionMessagingKit/Meta/SessionMessagingKit.h b/SessionMessagingKit/Meta/SessionMessagingKit.h index a0fda3d4a..1a9c8d33f 100644 --- a/SessionMessagingKit/Meta/SessionMessagingKit.h +++ b/SessionMessagingKit/Meta/SessionMessagingKit.h @@ -6,5 +6,4 @@ FOUNDATION_EXPORT const unsigned char SessionMessagingKitVersionString[]; #import #import #import -#import #import diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index 3e8370ddb..52cc02e49 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -512,7 +512,6 @@ public final class OpenGroupManager: NSObject { messages: [OpenGroupAPI.Message], for roomToken: String, on server: String, - isBackgroundPoll: Bool, dependencies: OGMDependencies = OGMDependencies() ) { // Sorting the messages by server ID before importing them fixes an issue where messages @@ -564,7 +563,6 @@ public final class OpenGroupManager: NSObject { message: messageInfo.message, associatedWithProto: try SNProtoContent.parseData(messageInfo.serializedProtoData), openGroupId: openGroup.id, - isBackgroundPoll: isBackgroundPoll, dependencies: dependencies ) } @@ -597,7 +595,6 @@ public final class OpenGroupManager: NSObject { messages: [OpenGroupAPI.DirectMessage], fromOutbox: Bool, on server: String, - isBackgroundPoll: Bool, dependencies: OGMDependencies = OGMDependencies() ) { // Don't need to do anything if we have no messages (it's a valid case) @@ -694,7 +691,6 @@ public final class OpenGroupManager: NSObject { message: messageInfo.message, associatedWithProto: try SNProtoContent.parseData(messageInfo.serializedProtoData), openGroupId: nil, // Intentionally nil as they are technically not open group messages - isBackgroundPoll: isBackgroundPoll, dependencies: dependencies ) } diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift index a18e0560b..78a4f4cdc 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift @@ -12,7 +12,6 @@ extension MessageReceiver { message: VisibleMessage, associatedWithProto proto: SNProtoContent, openGroupId: String?, - isBackgroundPoll: Bool, dependencies: Dependencies = Dependencies() ) throws -> Int64 { guard let sender: String = message.sender, let dataMessage = proto.dataMessage else { @@ -285,8 +284,7 @@ extension MessageReceiver { .notifyUser( db, for: interaction, - in: thread, - isBackgroundPoll: isBackgroundPoll + in: thread ) return interactionId diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift index 4524b75d2..db3c18a7e 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift @@ -180,7 +180,6 @@ public enum MessageReceiver { message: Message, associatedWithProto proto: SNProtoContent, openGroupId: String?, - isBackgroundPoll: Bool, dependencies: SMKDependencies = SMKDependencies() ) throws { switch message { @@ -206,7 +205,7 @@ public enum MessageReceiver { try MessageReceiver.handleUnsendRequest(db, message: message) case let message as CallMessage: - try MessageReceiver.handleCallMessage(db, message: message) + try MessageReceiver.handleCallMessage(db, message: message) case let message as MessageRequestResponse: try MessageReceiver.handleMessageRequestResponse(db, message: message, dependencies: dependencies) @@ -216,8 +215,7 @@ public enum MessageReceiver { db, message: message, associatedWithProto: proto, - openGroupId: openGroupId, - isBackgroundPoll: isBackgroundPoll + openGroupId: openGroupId ) default: fatalError() diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsProtocol.swift b/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsProtocol.swift index 0fefd991f..2622f7d2d 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsProtocol.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsProtocol.swift @@ -4,8 +4,14 @@ import Foundation import GRDB public protocol NotificationsProtocol { - func notifyUser(_ db: Database, for interaction: Interaction, in thread: SessionThread, isBackgroundPoll: Bool) + func notifyUser(_ db: Database, for interaction: Interaction, in thread: SessionThread) func notifyUser(_ db: Database, forIncomingCall interaction: Interaction, in thread: SessionThread) func cancelNotifications(identifiers: [String]) func clearAllNotifications() } + +public enum Notifications { + /// Delay notification of incoming messages when we want to group them (eg. during background polling) to avoid + /// firing too many notifications at the same time + public static let delayForGroupedNotifications: TimeInterval = 5 +} diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift index e87643a1b..40fccd4e4 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift @@ -145,7 +145,7 @@ public final class ClosedGroupPoller { _ groupPublicKey: String, on queue: DispatchQueue = SessionSnodeKit.Threading.workQueue, maxRetryCount: UInt = 0, - isBackgroundPoll: Bool = false, + calledFromBackgroundPoller: Bool = false, isBackgroundPollValid: @escaping (() -> Bool) = { true }, poller: ClosedGroupPoller? = nil ) -> Promise { @@ -156,7 +156,7 @@ public final class ClosedGroupPoller { return attempt(maxRetryCount: maxRetryCount, recoveringOn: queue) { guard - (isBackgroundPoll && isBackgroundPollValid()) || + (calledFromBackgroundPoller && isBackgroundPollValid()) || poller?.isPolling.wrappedValue[groupPublicKey] == true else { return Promise(error: Error.pollingCanceled) } @@ -178,7 +178,7 @@ public final class ClosedGroupPoller { return when(resolved: promises) .then(on: queue) { messageResults -> Promise in guard - (isBackgroundPoll && isBackgroundPollValid()) || + (calledFromBackgroundPoller && isBackgroundPollValid()) || poller?.isPolling.wrappedValue[groupPublicKey] == true else { return Promise.value(()) } @@ -195,7 +195,7 @@ public final class ClosedGroupPoller { // No need to do anything if there are no messages guard !allMessages.isEmpty else { - if !isBackgroundPoll { + if !calledFromBackgroundPoller { SNLog("Received no new messages in closed group with public key: \(groupPublicKey)") } return Promise.value(()) @@ -221,7 +221,7 @@ public final class ClosedGroupPoller { // In the background ignore 'SQLITE_ABORT' (it generally means // the BackgroundPoller has timed out case DatabaseError.SQLITE_ABORT: - guard !isBackgroundPoll else { break } + guard !calledFromBackgroundPoller else { break } SNLog("Failed to the database being suspended (running in background with no background task).") break @@ -241,16 +241,16 @@ public final class ClosedGroupPoller { threadId: groupPublicKey, details: MessageReceiveJob.Details( messages: processedMessages.map { $0.messageInfo }, - isBackgroundPoll: isBackgroundPoll + calledFromBackgroundPoller: calledFromBackgroundPoller ) ) // If we are force-polling then add to the JobRunner so they are persistent and will retry on // the next app run if they fail but don't let them auto-start - JobRunner.add(db, job: jobToRun, canStartJob: !isBackgroundPoll) + JobRunner.add(db, job: jobToRun, canStartJob: !calledFromBackgroundPoller) } - if isBackgroundPoll { + if calledFromBackgroundPoller { // We want to try to handle the receive jobs immediately in the background promises = promises.appending( jobToRun.map { job -> Promise in @@ -278,7 +278,7 @@ public final class ClosedGroupPoller { } } - if !isBackgroundPoll { + if !calledFromBackgroundPoller { promise.catch2 { error in SNLog("Polling failed for closed group with public key: \(groupPublicKey) due to error: \(error).") } diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift index 7f270a4c4..4a83d07b6 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift @@ -67,12 +67,12 @@ extension OpenGroupAPI { @discardableResult public func poll(using dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies()) -> Promise { - return poll(isBackgroundPoll: false, isPostCapabilitiesRetry: false, using: dependencies) + return poll(calledFromBackgroundPoller: false, isPostCapabilitiesRetry: false, using: dependencies) } @discardableResult public func poll( - isBackgroundPoll: Bool, + calledFromBackgroundPoller: Bool, isBackgroundPollerValid: @escaping (() -> Bool) = { true }, isPostCapabilitiesRetry: Bool, using dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies() @@ -107,7 +107,7 @@ extension OpenGroupAPI { .map(on: OpenGroupAPI.workQueue) { (failureCount, $0) } } .done(on: OpenGroupAPI.workQueue) { [weak self] failureCount, response in - guard !isBackgroundPoll || isBackgroundPollerValid() else { + guard !calledFromBackgroundPoller || isBackgroundPollerValid() else { // If this was a background poll and the background poll is no longer valid // then just stop self?.isPolling = false @@ -119,7 +119,6 @@ extension OpenGroupAPI { self?.handlePollResponse( response, failureCount: failureCount, - isBackgroundPoll: isBackgroundPoll, using: dependencies ) @@ -133,7 +132,7 @@ extension OpenGroupAPI { seal.fulfill(()) } .catch(on: OpenGroupAPI.workQueue) { [weak self] error in - guard !isBackgroundPoll || isBackgroundPollerValid() else { + guard !calledFromBackgroundPoller || isBackgroundPollerValid() else { // If this was a background poll and the background poll is no longer valid // then just stop self?.isPolling = false @@ -145,7 +144,8 @@ extension OpenGroupAPI { // method will always resolve) self?.updateCapabilitiesAndRetryIfNeeded( server: server, - isBackgroundPoll: isBackgroundPoll, + calledFromBackgroundPoller: calledFromBackgroundPoller, + isBackgroundPollerValid: isBackgroundPollerValid, isPostCapabilitiesRetry: isPostCapabilitiesRetry, error: error ) @@ -186,7 +186,8 @@ extension OpenGroupAPI { private func updateCapabilitiesAndRetryIfNeeded( server: String, - isBackgroundPoll: Bool, + calledFromBackgroundPoller: Bool, + isBackgroundPollerValid: @escaping (() -> Bool) = { true }, isPostCapabilitiesRetry: Bool, error: Error, using dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies() @@ -233,7 +234,8 @@ extension OpenGroupAPI { // Regardless of the outcome we can just resolve this // immediately as it'll handle it's own response return strongSelf.poll( - isBackgroundPoll: isBackgroundPoll, + calledFromBackgroundPoller: calledFromBackgroundPoller, + isBackgroundPollerValid: isBackgroundPollerValid, isPostCapabilitiesRetry: true, using: dependencies ) @@ -251,7 +253,6 @@ extension OpenGroupAPI { private func handlePollResponse( _ response: PollResponse, failureCount: Int64, - isBackgroundPoll: Bool, using dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies() ) { let server: String = self.server @@ -440,7 +441,6 @@ extension OpenGroupAPI { messages: responseBody.compactMap { $0.value }, for: roomToken, on: server, - isBackgroundPoll: isBackgroundPoll, dependencies: dependencies ) @@ -464,7 +464,6 @@ extension OpenGroupAPI { messages: messages, fromOutbox: fromOutbox, on: server, - isBackgroundPoll: isBackgroundPoll, dependencies: dependencies ) diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift index 4877077d2..02a3d139a 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift @@ -173,7 +173,7 @@ public final class Poller { threadId: threadId, details: MessageReceiveJob.Details( messages: threadMessages.map { $0.messageInfo }, - isBackgroundPoll: false + calledFromBackgroundPoller: false ) ) ) diff --git a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift index 58e07f7e5..d9eea8da0 100644 --- a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift +++ b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift @@ -350,7 +350,6 @@ public extension SessionThreadViewModel { /// but including this warning just in case there is a discrepancy) static func baseQuery( userPublicKey: String, - filterSQL: SQL, groupSQL: SQL, orderSQL: SQL ) -> (([Int64]) -> AdaptedFetchRequest>) { @@ -368,6 +367,7 @@ public extension SessionThreadViewModel { let interactionAttachment: TypedTableAlias = TypedTableAlias() let profile: TypedTableAlias = TypedTableAlias() + let interactionTimestampMsColumnLiteral: SQL = SQL(stringLiteral: Interaction.Columns.timestampMs.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) @@ -412,7 +412,7 @@ public extension SessionThreadViewModel { \(Interaction.self).\(ViewModel.interactionIdKey), \(Interaction.self).\(ViewModel.interactionVariantKey), - \(Interaction.self).\(ViewModel.interactionTimestampMsKey), + \(Interaction.self).\(interactionTimestampMsColumnLiteral) AS \(ViewModel.interactionTimestampMsKey), \(Interaction.self).\(ViewModel.interactionBodyKey), -- Default to 'sending' assuming non-processed interaction when null @@ -440,7 +440,7 @@ public extension SessionThreadViewModel { \(interaction[.id]) AS \(ViewModel.interactionIdKey), \(interaction[.threadId]), \(interaction[.variant]) AS \(ViewModel.interactionVariantKey), - MAX(\(interaction[.timestampMs])) AS \(ViewModel.interactionTimestampMsKey), + MAX(\(interaction[.timestampMs])) AS \(interactionTimestampMsColumnLiteral), \(interaction[.body]) AS \(ViewModel.interactionBodyKey), \(interaction[.authorId]), \(interaction[.linkPreviewUrl]), @@ -461,7 +461,7 @@ public extension SessionThreadViewModel { LEFT JOIN \(LinkPreview.self) ON ( \(linkPreview[.url]) = \(interaction[.linkPreviewUrl]) AND \(SQL("\(linkPreview[.variant]) = \(LinkPreview.Variant.openGroupInvitation)")) AND - \(Interaction.linkPreviewFilterLiteral(timestampColumn: ViewModel.interactionTimestampMsKey)) + \(Interaction.linkPreviewFilterLiteral(timestampColumn: interactionTimestampMsColumnLiteral)) ) LEFT JOIN \(InteractionAttachment.self) AS \(firstInteractionAttachmentLiteral) ON ( \(firstInteractionAttachmentLiteral).\(interactionAttachmentAlbumIndexColumnLiteral) = 0 AND @@ -545,12 +545,14 @@ public extension SessionThreadViewModel { let contact: TypedTableAlias = TypedTableAlias() let interaction: TypedTableAlias = TypedTableAlias() + let interactionTimestampMsColumnLiteral: SQL = SQL(stringLiteral: Interaction.Columns.timestampMs.name) + return """ LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id]) LEFT JOIN ( SELECT \(interaction[.threadId]), - MAX(\(interaction[.timestampMs])) AS \(ViewModel.interactionTimestampMsKey) + MAX(\(interaction[.timestampMs])) AS \(interactionTimestampMsColumnLiteral) FROM \(Interaction.self) WHERE \(SQL("\(interaction[.variant]) != \(Interaction.Variant.standardIncomingDeleted)")) GROUP BY \(interaction[.threadId]) @@ -561,6 +563,7 @@ public extension SessionThreadViewModel { static func homeFilterSQL(userPublicKey: String) -> SQL { let thread: TypedTableAlias = TypedTableAlias() let contact: TypedTableAlias = TypedTableAlias() + let interaction: TypedTableAlias = TypedTableAlias() return """ \(thread[.shouldBeVisible]) = true AND ( @@ -571,7 +574,7 @@ public extension SessionThreadViewModel { ) AND ( -- Only show the 'Note to Self' thread if it has an interaction \(SQL("\(thread[.id]) != \(userPublicKey)")) OR - \(Interaction.self).\(ViewModel.interactionTimestampMsKey) IS NOT NULL + \(interaction[.timestampMs]) IS NOT NULL ) """ } @@ -598,14 +601,16 @@ public extension SessionThreadViewModel { static let homeOrderSQL: SQL = { let thread: TypedTableAlias = TypedTableAlias() + let interaction: TypedTableAlias = TypedTableAlias() - return SQL("\(thread[.isPinned]) DESC, IFNULL(\(Interaction.self).\(ViewModel.interactionTimestampMsKey), (\(thread[.creationDateTimestamp]) * 1000)) DESC") + return SQL("\(thread[.isPinned]) DESC, IFNULL(\(interaction[.timestampMs]), (\(thread[.creationDateTimestamp]) * 1000)) DESC") }() static let messageRequetsOrderSQL: SQL = { let thread: TypedTableAlias = TypedTableAlias() + let interaction: TypedTableAlias = TypedTableAlias() - return SQL("IFNULL(\(Interaction.self).\(ViewModel.interactionTimestampMsKey), (\(thread[.creationDateTimestamp]) * 1000)) DESC") + return SQL("IFNULL(\(interaction[.timestampMs]), (\(thread[.creationDateTimestamp]) * 1000)) DESC") }() } @@ -684,7 +689,7 @@ public extension SessionThreadViewModel { SUM(\(interaction[.wasRead]) = false) AS \(ViewModel.threadUnreadCountKey) FROM \(Interaction.self) - GROUP BY \(interaction[.threadId]) + WHERE \(SQL("\(interaction[.threadId]) = \(threadId)")) ) AS \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id]) LEFT JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON \(ViewModel.contactProfileKey).\(profileIdColumnLiteral) = \(thread[.id]) @@ -698,17 +703,15 @@ public extension SessionThreadViewModel { LEFT JOIN ( SELECT \(groupMember[.groupId]), - COUNT(*) AS \(ViewModel.closedGroupUserCountKey) + COUNT(\(groupMember.alias[Column.rowID])) 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)")) WHERE \(SQL("\(thread[.id]) = \(threadId)")) - GROUP BY \(thread[.id]) """ return request.adapted { db in diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift index 9a598a15b..2158aa3e6 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift @@ -2120,7 +2120,6 @@ class OpenGroupManagerSpec: QuickSpec { ], for: "testRoom", on: "testServer", - isBackgroundPoll: false, dependencies: dependencies ) } @@ -2142,7 +2141,6 @@ class OpenGroupManagerSpec: QuickSpec { messages: [], for: "testRoom", on: "testServer", - isBackgroundPoll: false, dependencies: dependencies ) } @@ -2182,7 +2180,6 @@ class OpenGroupManagerSpec: QuickSpec { ], for: "testRoom", on: "testServer", - isBackgroundPoll: false, dependencies: dependencies ) } @@ -2215,7 +2212,6 @@ class OpenGroupManagerSpec: QuickSpec { ], for: "testRoom", on: "testServer", - isBackgroundPoll: false, dependencies: dependencies ) } @@ -2230,7 +2226,6 @@ class OpenGroupManagerSpec: QuickSpec { messages: [testMessage], for: "testRoom", on: "testServer", - isBackgroundPoll: false, dependencies: dependencies ) } @@ -2260,7 +2255,6 @@ class OpenGroupManagerSpec: QuickSpec { ], for: "testRoom", on: "testServer", - isBackgroundPoll: false, dependencies: dependencies ) } @@ -2298,7 +2292,6 @@ class OpenGroupManagerSpec: QuickSpec { ], for: "testRoom", on: "testServer", - isBackgroundPoll: false, dependencies: dependencies ) } @@ -2327,7 +2320,6 @@ class OpenGroupManagerSpec: QuickSpec { ], for: "testRoom", on: "testServer", - isBackgroundPoll: false, dependencies: dependencies ) } @@ -2379,7 +2371,6 @@ class OpenGroupManagerSpec: QuickSpec { messages: [], fromOutbox: false, on: "testServer", - isBackgroundPoll: false, dependencies: dependencies ) } @@ -2413,7 +2404,6 @@ class OpenGroupManagerSpec: QuickSpec { messages: [testDirectMessage], fromOutbox: false, on: "testServer", - isBackgroundPoll: false, dependencies: dependencies ) } @@ -2452,7 +2442,6 @@ class OpenGroupManagerSpec: QuickSpec { messages: [testDirectMessage], fromOutbox: false, on: "testServer", - isBackgroundPoll: false, dependencies: dependencies ) } @@ -2478,7 +2467,6 @@ class OpenGroupManagerSpec: QuickSpec { messages: [testDirectMessage], fromOutbox: false, on: "testServer", - isBackgroundPoll: false, dependencies: dependencies ) } @@ -2509,7 +2497,6 @@ class OpenGroupManagerSpec: QuickSpec { messages: [testDirectMessage], fromOutbox: false, on: "testServer", - isBackgroundPoll: false, dependencies: dependencies ) } @@ -2524,7 +2511,6 @@ class OpenGroupManagerSpec: QuickSpec { messages: [testDirectMessage], fromOutbox: false, on: "testServer", - isBackgroundPoll: false, dependencies: dependencies ) } @@ -2549,7 +2535,6 @@ class OpenGroupManagerSpec: QuickSpec { ], fromOutbox: false, on: "testServer", - isBackgroundPoll: false, dependencies: dependencies ) } @@ -2576,7 +2561,6 @@ class OpenGroupManagerSpec: QuickSpec { messages: [testDirectMessage], fromOutbox: true, on: "testServer", - isBackgroundPoll: false, dependencies: dependencies ) } @@ -2607,7 +2591,6 @@ class OpenGroupManagerSpec: QuickSpec { messages: [testDirectMessage], fromOutbox: true, on: "testServer", - isBackgroundPoll: false, dependencies: dependencies ) } @@ -2623,7 +2606,6 @@ class OpenGroupManagerSpec: QuickSpec { messages: [testDirectMessage], fromOutbox: true, on: "testServer", - isBackgroundPoll: false, dependencies: dependencies ) } @@ -2659,7 +2641,6 @@ class OpenGroupManagerSpec: QuickSpec { messages: [testDirectMessage], fromOutbox: true, on: "testServer", - isBackgroundPoll: false, dependencies: dependencies ) } @@ -2674,7 +2655,6 @@ class OpenGroupManagerSpec: QuickSpec { messages: [testDirectMessage], fromOutbox: true, on: "testServer", - isBackgroundPoll: false, dependencies: dependencies ) } @@ -2699,7 +2679,6 @@ class OpenGroupManagerSpec: QuickSpec { ], fromOutbox: true, on: "testServer", - isBackgroundPoll: false, dependencies: dependencies ) } diff --git a/SessionNotificationServiceExtension/NSENotificationPresenter.swift b/SessionNotificationServiceExtension/NSENotificationPresenter.swift index 04503ed5f..40b5c01ee 100644 --- a/SessionNotificationServiceExtension/NSENotificationPresenter.swift +++ b/SessionNotificationServiceExtension/NSENotificationPresenter.swift @@ -9,7 +9,7 @@ import SessionMessagingKit public class NSENotificationPresenter: NSObject, NotificationsProtocol { private var notifications: [String: UNNotificationRequest] = [:] - public func notifyUser(_ db: Database, for interaction: Interaction, in thread: SessionThread, isBackgroundPoll: Bool) { + public func notifyUser(_ db: Database, for interaction: Interaction, in thread: SessionThread) { let isMessageRequest: Bool = thread.isMessageRequest(db, includeNonVisible: true) // Ensure we should be showing a notification for the thread @@ -18,6 +18,12 @@ public class NSENotificationPresenter: NSObject, NotificationsProtocol { } let senderName: String = Profile.displayName(db, id: interaction.authorId, threadVariant: thread.variant) + let groupName: String = SessionThread.displayName( + threadId: thread.id, + variant: thread.variant, + closedGroupName: (try? thread.closedGroup.fetchOne(db))?.name, + openGroupName: (try? thread.openGroup.fetchOne(db))?.name + ) var notificationTitle: String = senderName if thread.variant == .closedGroup || thread.variant == .openGroup { @@ -26,22 +32,11 @@ public class NSENotificationPresenter: NSObject, NotificationsProtocol { return } - notificationTitle = { - let groupName: String = SessionThread.displayName( - threadId: thread.id, - variant: thread.variant, - closedGroupName: (try? thread.closedGroup.fetchOne(db))?.name, - openGroupName: (try? thread.openGroup.fetchOne(db))?.name - ) - - guard !isBackgroundPoll else { return groupName } - - return String( - format: NotificationStrings.incomingGroupMessageTitleFormat, - senderName, - groupName - ) - }() + notificationTitle = String( + format: NotificationStrings.incomingGroupMessageTitleFormat, + senderName, + groupName + ) } let snippet: String = (interaction.previewText(db) @@ -88,21 +83,31 @@ public class NSENotificationPresenter: NSObject, NotificationsProtocol { notificationContent.body = "MESSAGE_REQUESTS_NOTIFICATION".localized() } - // Add request - let identifier = interaction.notificationIdentifier(isBackgroundPoll: isBackgroundPoll) + // Add request (try to group notifications for interactions from open groups) + let identifier: String = interaction.notificationIdentifier( + shouldGroupMessagesForThread: (thread.variant == .openGroup) + ) var trigger: UNNotificationTrigger? - if isBackgroundPoll { - trigger = UNTimeIntervalNotificationTrigger(timeInterval: 5, repeats: false) + if thread.variant == .openGroup { + trigger = UNTimeIntervalNotificationTrigger( + timeInterval: Notifications.delayForGroupedNotifications, + repeats: false + ) - var numberOfNotifications: Int = (notifications[identifier]? + let numberExistingNotifications: Int? = notifications[identifier]? .content .userInfo[NotificationServiceExtension.threadNotificationCounter] - .asType(Int.self)) - .defaulting(to: 1) + .asType(Int.self) + var numberOfNotifications: Int = (numberExistingNotifications ?? 1) - if numberOfNotifications > 1 { + if numberExistingNotifications != nil { numberOfNotifications += 1 // Add one for the current notification + + notificationContent.title = (previewType == .noNameNoPreview ? + notificationContent.title : + groupName + ) notificationContent.body = String( format: NotificationStrings.incomingCollapsedMessagesBody, "\(numberOfNotifications)" @@ -112,7 +117,11 @@ public class NSENotificationPresenter: NSObject, NotificationsProtocol { notificationContent.userInfo[NotificationServiceExtension.threadNotificationCounter] = numberOfNotifications } - let request = UNNotificationRequest(identifier: identifier, content: notificationContent, trigger: trigger) + let request = UNNotificationRequest( + identifier: identifier, + content: notificationContent, + trigger: trigger + ) SNLog("Add remote notification request: \(notificationContent.body)") let semaphore = DispatchSemaphore(value: 0) diff --git a/SessionNotificationServiceExtension/NotificationServiceExtension.swift b/SessionNotificationServiceExtension/NotificationServiceExtension.swift index df3fed80c..5705a4661 100644 --- a/SessionNotificationServiceExtension/NotificationServiceExtension.swift +++ b/SessionNotificationServiceExtension/NotificationServiceExtension.swift @@ -83,8 +83,7 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension db, message: visibleMessage, associatedWithProto: processedMessage.proto, - openGroupId: (isOpenGroup ? processedMessage.threadId : nil), - isBackgroundPoll: false + openGroupId: (isOpenGroup ? processedMessage.threadId : nil) ) // Remove the notifications if there is an outgoing messages from a linked device @@ -329,7 +328,7 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension .defaulting(to: []) .map { server in OpenGroupAPI.Poller(for: server) - .poll(isBackgroundPoll: true, isPostCapabilitiesRetry: false) + .poll(calledFromBackgroundPoller: true, isPostCapabilitiesRetry: false) .timeout( seconds: 20, timeoutError: NotificationServiceError.timeout diff --git a/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift b/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift index bdc17323e..9b155386e 100644 --- a/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift +++ b/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift @@ -379,7 +379,8 @@ public class PagedDatabaseObserver: TransactionObserver where let orderSQL: SQL = self.orderSQL let dataQuery: ([Int64]) -> AdaptedFetchRequest> = self.dataQuery - let loadedPage: (data: [T]?, pageInfo: PagedData.PageInfo)? = Storage.shared.read { [weak self] db in + let loadedPage: (data: [T]?, pageInfo: PagedData.PageInfo, failureCallback: (() -> ())?)? = Storage.shared.read { [weak self] db in + typealias QueryInfo = (limit: Int, offset: Int, updatedCacheOffset: Int) let totalCount: Int = PagedData.totalCount( db, tableName: pagedTableName, @@ -387,7 +388,7 @@ public class PagedDatabaseObserver: TransactionObserver where filterSQL: filterSQL ) - let queryInfo: (limit: Int, offset: Int, updatedCacheOffset: Int)? = { + let (queryInfo, callback): (QueryInfo?, (() -> ())?) = { switch target { case .initialPageAround(let targetId): // If we want to focus on a specific item then we need to find it's index in @@ -404,7 +405,7 @@ public class PagedDatabaseObserver: TransactionObserver where // If we couldn't find the targetId then just load the first page guard let targetIndex: Int = maybeIndex else { - return (currentPageInfo.pageSize, 0, 0) + return ((currentPageInfo.pageSize, 0, 0), nil) } let updatedOffset: Int = { @@ -421,22 +422,28 @@ public class PagedDatabaseObserver: TransactionObserver where return (targetIndex - halfPageSize) }() - return (currentPageInfo.pageSize, updatedOffset, updatedOffset) + return ((currentPageInfo.pageSize, updatedOffset, updatedOffset), nil) case .pageBefore: let updatedOffset: Int = max(0, (currentPageInfo.pageOffset - currentPageInfo.pageSize)) return ( - currentPageInfo.pageSize, - updatedOffset, - updatedOffset + ( + currentPageInfo.pageSize, + updatedOffset, + updatedOffset + ), + nil ) case .pageAfter: return ( - currentPageInfo.pageSize, - (currentPageInfo.pageOffset + currentPageInfo.currentCount), - currentPageInfo.pageOffset + ( + currentPageInfo.pageSize, + (currentPageInfo.pageOffset + currentPageInfo.currentCount), + currentPageInfo.pageOffset + ), + nil ) case .untilInclusive(let targetId, let padding): @@ -459,16 +466,19 @@ public class PagedDatabaseObserver: TransactionObserver where targetIndex < currentPageInfo.pageOffset || targetIndex >= cacheCurrentEndIndex ) - else { return nil } + else { return (nil, 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 + ( + (currentPageInfo.pageOffset - finalIndex), + finalIndex, + finalIndex + ), + nil ) } @@ -477,23 +487,81 @@ public class PagedDatabaseObserver: TransactionObserver where let finalIndex: Int = min(totalCount, (targetIndex + 1 + abs(padding))) return ( - (finalIndex - cacheCurrentEndIndex), - cacheCurrentEndIndex, - currentPageInfo.pageOffset + ( + (finalIndex - cacheCurrentEndIndex), + cacheCurrentEndIndex, + currentPageInfo.pageOffset + ), + nil ) + case .jumpTo(let targetId, let paddingForInclusive): + // 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, nil) } + + // If the target id is within a single page of the current cached data + // then trigger the `untilInclusive` behaviour instead + guard + abs(targetIndex - cacheCurrentEndIndex) > currentPageInfo.pageSize || + abs(targetIndex - currentPageInfo.pageOffset) > currentPageInfo.pageSize + else { + let callback: () -> () = { + self?.load(.untilInclusive(id: targetId, padding: paddingForInclusive)) + } + return (nil, callback) + } + + // If the targetId is further than 1 pageSize away then discard the current + // cached data and trigger a fresh `initialPageAround` + let callback: () -> () = { + self?.dataCache.mutate { $0 = DataCache() } + self?.associatedRecords.forEach { $0.clearCache(db) } + self?.pageInfo.mutate { + $0 = PagedData.PageInfo( + pageSize: currentPageInfo.pageSize, + pageOffset: 0, + currentCount: 0, + totalCount: 0 + ) + } + self?.load(.initialPageAround(id: targetId)) + } + + return (nil, callback) + case .reloadCurrent: return ( - currentPageInfo.currentCount, - currentPageInfo.pageOffset, - currentPageInfo.pageOffset + ( + currentPageInfo.currentCount, + currentPageInfo.pageOffset, + currentPageInfo.pageOffset + ), + nil ) } }() // 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 { + guard let queryInfo: QueryInfo = queryInfo else { return ( nil, PagedData.PageInfo( @@ -501,7 +569,8 @@ public class PagedDatabaseObserver: TransactionObserver where pageOffset: currentPageInfo.pageOffset, currentCount: currentPageInfo.currentCount, totalCount: totalCount - ) + ), + callback ) } @@ -540,7 +609,7 @@ public class PagedDatabaseObserver: TransactionObserver where ) } - return (newData, updatedLimitInfo) + return (newData, updatedLimitInfo, nil) } // Unwrap the updated data @@ -554,6 +623,7 @@ public class PagedDatabaseObserver: TransactionObserver where self.pageInfo.mutate { $0 = updatedPageInfo } } self.isLoadingMoreData.mutate { $0 = false } + loadedPage?.failureCallback?() return } @@ -651,6 +721,7 @@ public protocol ErasedAssociatedRecord { pageInfo: PagedData.PageInfo ) -> Bool @discardableResult func updateCache(_ db: Database, rowIds: [Int64], hasOtherChanges: Bool) -> Bool + func clearCache(_ db: Database) func attachAssociatedData(to unassociatedCache: DataCache) -> DataCache } @@ -733,6 +804,7 @@ public enum PagedData { case pageBefore case pageAfter case untilInclusive(id: SQLExpression, padding: Int) + case jumpTo(id: SQLExpression, paddingForInclusive: Int) case reloadCurrent } @@ -755,6 +827,13 @@ public enum PagedData { /// the padding would mean more data should be loaded) case untilInclusive(id: ID, padding: Int) + /// This will jump to the specified id, loading a page around it and clearing out any + /// data that was previously cached + /// + /// **Note:** If the id is within 1 pageSize of the currently cached data then this + /// will behave as per the `untilInclusive(id:padding:)` type + case jumpTo(id: ID, paddingForInclusive: Int) + fileprivate var internalTarget: InternalTarget { switch self { case .initialPageAround(let id): return .initialPageAround(id: id.sqlExpression) @@ -762,6 +841,9 @@ public enum PagedData { case .pageAfter: return .pageAfter case .untilInclusive(let id, let padding): return .untilInclusive(id: id.sqlExpression, padding: padding) + + case .jumpTo(let id, let paddingForInclusive): + return .jumpTo(id: id.sqlExpression, paddingForInclusive: paddingForInclusive) } } } @@ -1144,6 +1226,10 @@ public class AssociatedRecord: ErasedAssociatedRecord where T: Fet return true } + public func clearCache(_ db: Database) { + dataCache.mutate { $0 = DataCache() } + } + public func attachAssociatedData(to unassociatedCache: DataCache) -> DataCache { guard let typedCache: DataCache = unassociatedCache as? DataCache else { return unassociatedCache diff --git a/SessionUtilitiesKit/JobRunner/JobRunner.swift b/SessionUtilitiesKit/JobRunner/JobRunner.swift index c84a11283..b49666600 100644 --- a/SessionUtilitiesKit/JobRunner/JobRunner.swift +++ b/SessionUtilitiesKit/JobRunner/JobRunner.swift @@ -103,6 +103,7 @@ public final class JobRunner { internal static var executorMap: Atomic<[Job.Variant: JobExecutor.Type]> = Atomic([:]) fileprivate static var perSessionJobsCompleted: Atomic> = Atomic([]) private static var hasCompletedInitialBecomeActive: Atomic = Atomic(false) + private static var shutdownBackgroundTask: Atomic = Atomic(nil) // MARK: - Configuration @@ -222,6 +223,14 @@ public final class JobRunner { } public static func appDidBecomeActive() { + // If we have a running "sutdownBackgroundTask" then we want to cancel it as otherwise it + // can result in the database being suspended and us being unable to interact with it at all + shutdownBackgroundTask.mutate { + $0?.cancel() + $0 = nil + } + + // Retrieve any jobs which should run when becoming active let hasCompletedInitialBecomeActive: Bool = JobRunner.hasCompletedInitialBecomeActive.wrappedValue let jobsToRun: [Job] = Storage.shared .read { db in @@ -259,9 +268,56 @@ public final class JobRunner { /// Calling this will clear the JobRunner queues and stop it from running new jobs, any currently executing jobs will continue to run /// though (this means if we suspend the database it's likely that any currently running jobs will fail to complete and fail to record their /// failure - they _should_ be picked up again the next time the app is launched) - public static func stopAndClearPendingJobs() { - queues.wrappedValue.values.forEach { queue in - queue.stopAndClearPendingJobs() + public static func stopAndClearPendingJobs( + exceptForVariant: Job.Variant? = nil, + onComplete: (() -> ())? = nil + ) { + // Stop all queues except for the one containing the `exceptForVariant` + queues.wrappedValue + .values + .filter { queue -> Bool in + guard let exceptForVariant: Job.Variant = exceptForVariant else { return true } + + return !queue.jobVariants.contains(exceptForVariant) + } + .forEach { $0.stopAndClearPendingJobs() } + + // Ensure the queue is actually running (if not the trigger the callback immediately) + guard + let exceptForVariant: Job.Variant = exceptForVariant, + let queue: JobQueue = queues.wrappedValue[exceptForVariant], + queue.isRunning.wrappedValue == true + else { + onComplete?() + return + } + + let oldQueueDrained: (() -> ())? = queue.onQueueDrained + + // Create a backgroundTask to give the queue the chance to properly be drained + shutdownBackgroundTask.mutate { + $0 = OWSBackgroundTask(labelStr: #function) { [weak queue] state in + // If the background task didn't succeed then trigger the onComplete (and hope we have + // enough time to complete it's logic) + guard state != .cancelled else { + queue?.onQueueDrained = oldQueueDrained + return + } + guard state != .success else { return } + + onComplete?() + queue?.onQueueDrained = oldQueueDrained + queue?.stopAndClearPendingJobs() + } + } + + // Add a callback to be triggered once the queue is drained + queue.onQueueDrained = { [weak queue] in + oldQueueDrained?() + queue?.onQueueDrained = oldQueueDrained + onComplete?() + + shutdownBackgroundTask.mutate { $0 = nil } } } @@ -370,7 +426,7 @@ private final class JobQueue { /// The specific types of jobs this queue manages, if this is left empty it will handle all jobs not handled by other queues fileprivate let jobVariants: [Job.Variant] - private let onQueueDrained: (() -> ())? + fileprivate var onQueueDrained: (() -> ())? private lazy var internalQueue: DispatchQueue = { let result: DispatchQueue = DispatchQueue( diff --git a/SessionUtilitiesKit/Meta/SessionUtilitiesKit.h b/SessionUtilitiesKit/Meta/SessionUtilitiesKit.h index 8624165de..1a6f9e631 100644 --- a/SessionUtilitiesKit/Meta/SessionUtilitiesKit.h +++ b/SessionUtilitiesKit/Meta/SessionUtilitiesKit.h @@ -15,4 +15,5 @@ FOUNDATION_EXPORT const unsigned char SessionUtilitiesKitVersionString[]; #import #import #import +#import diff --git a/SessionMessagingKit/Utilities/OWSBackgroundTask.h b/SessionUtilitiesKit/Utilities/OWSBackgroundTask.h similarity index 97% rename from SessionMessagingKit/Utilities/OWSBackgroundTask.h rename to SessionUtilitiesKit/Utilities/OWSBackgroundTask.h index 70a9fbdd0..73e632cd6 100644 --- a/SessionMessagingKit/Utilities/OWSBackgroundTask.h +++ b/SessionUtilitiesKit/Utilities/OWSBackgroundTask.h @@ -10,6 +10,7 @@ typedef NS_ENUM(NSUInteger, BackgroundTaskState) { BackgroundTaskState_Success, BackgroundTaskState_CouldNotStart, BackgroundTaskState_Expired, + BackgroundTaskState_Cancelled, }; typedef void (^BackgroundTaskCompletionBlock)(BackgroundTaskState backgroundTaskState); @@ -57,6 +58,8 @@ typedef void (^BackgroundTaskCompletionBlock)(BackgroundTaskState backgroundTask + (OWSBackgroundTask *)backgroundTaskWithLabel:(NSString *)label completionBlock:(BackgroundTaskCompletionBlock)completionBlock; +- (void)cancel; + @end NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Utilities/OWSBackgroundTask.m b/SessionUtilitiesKit/Utilities/OWSBackgroundTask.m similarity index 95% rename from SessionMessagingKit/Utilities/OWSBackgroundTask.m rename to SessionUtilitiesKit/Utilities/OWSBackgroundTask.m index 3ce898431..7602df9e6 100644 --- a/SessionMessagingKit/Utilities/OWSBackgroundTask.m +++ b/SessionUtilitiesKit/Utilities/OWSBackgroundTask.m @@ -375,6 +375,31 @@ typedef NSNumber *OWSTaskId; } } +- (void)cancel +{ + // Make a local copy of this state, since this method is called by `dealloc`. + BackgroundTaskCompletionBlock _Nullable completionBlock; + + @synchronized(self) + { + if (!self.taskId) { + return; + } + [OWSBackgroundTaskManager.sharedManager removeTask:self.taskId]; + self.taskId = nil; + + completionBlock = self.completionBlock; + self.completionBlock = nil; + } + + // endBackgroundTask must be called on the main thread. + DispatchMainThreadSafe(^{ + if (completionBlock) { + completionBlock(BackgroundTaskState_Cancelled); + } + }); +} + - (void)endBackgroundTask { // Make a local copy of this state, since this method is called by `dealloc`. diff --git a/SignalUtilitiesKit/Profile Pictures/ProfilePictureView.swift b/SignalUtilitiesKit/Profile Pictures/ProfilePictureView.swift index bb8b477b1..142f606e9 100644 --- a/SignalUtilitiesKit/Profile Pictures/ProfilePictureView.swift +++ b/SignalUtilitiesKit/Profile Pictures/ProfilePictureView.swift @@ -57,10 +57,23 @@ public final class ProfilePictureView: UIView { return result }() + private lazy var additionalProfilePlaceholderImageView: UIImageView = { + let result: UIImageView = UIImageView( + image: UIImage(systemName: "person.fill")?.withRenderingMode(.alwaysTemplate) + ) + result.translatesAutoresizingMaskIntoConstraints = false + result.contentMode = .scaleAspectFill + result.tintColor = Colors.text + result.isHidden = true + + return result + }() + private lazy var additionalImageView: UIImageView = { let result: UIImageView = UIImageView() result.translatesAutoresizingMaskIntoConstraints = false result.contentMode = .scaleAspectFill + result.tintColor = Colors.text result.isHidden = true return result @@ -107,11 +120,17 @@ public final class ProfilePictureView: UIView { imageContainerView.addSubview(animatedImageView) additionalImageContainerView.addSubview(additionalImageView) additionalImageContainerView.addSubview(additionalAnimatedImageView) + additionalImageContainerView.addSubview(additionalProfilePlaceholderImageView) imageView.pin(to: imageContainerView) animatedImageView.pin(to: imageContainerView) additionalImageView.pin(to: additionalImageContainerView) additionalAnimatedImageView.pin(to: additionalImageContainerView) + + additionalProfilePlaceholderImageView.pin(.top, to: .top, of: additionalImageContainerView, withInset: 3) + additionalProfilePlaceholderImageView.pin(.left, to: .left, of: additionalImageContainerView) + additionalProfilePlaceholderImageView.pin(.right, to: .right, of: additionalImageContainerView) + additionalProfilePlaceholderImageView.pin(.bottom, to: .bottom, of: additionalImageContainerView, withInset: 3) } // FIXME: Remove this once we refactor the OWSConversationSettingsViewController to Swift (use the HomeViewModel approach) @@ -172,6 +191,7 @@ public final class ProfilePictureView: UIView { additionalAnimatedImageView.image = nil additionalImageView.isHidden = true additionalAnimatedImageView.isHidden = true + additionalProfilePlaceholderImageView.isHidden = true return } guard !publicKey.isEmpty || openGroupProfilePictureData != nil else { return } @@ -240,6 +260,12 @@ public final class ProfilePictureView: UIView { additionalAnimatedImageView.image = animatedImage additionalImageView.isHidden = (animatedImage != nil) additionalAnimatedImageView.isHidden = (animatedImage == nil) + additionalProfilePlaceholderImageView.isHidden = true + } + else { + additionalImageView.isHidden = true + additionalAnimatedImageView.isHidden = true + additionalProfilePlaceholderImageView.isHidden = false } default: @@ -251,6 +277,7 @@ public final class ProfilePictureView: UIView { additionalImageView.isHidden = true additionalAnimatedImageView.image = nil additionalAnimatedImageView.isHidden = true + additionalProfilePlaceholderImageView.isHidden = true } // Set the image diff --git a/SignalUtilitiesKit/Utilities/NoopNotificationsManager.swift b/SignalUtilitiesKit/Utilities/NoopNotificationsManager.swift index 76b31539a..909a9f347 100644 --- a/SignalUtilitiesKit/Utilities/NoopNotificationsManager.swift +++ b/SignalUtilitiesKit/Utilities/NoopNotificationsManager.swift @@ -7,7 +7,7 @@ import SessionMessagingKit public class NoopNotificationsManager: NotificationsProtocol { public init() {} - public func notifyUser(_ db: Database, for interaction: Interaction, in thread: SessionThread, isBackgroundPoll: Bool) { + public func notifyUser(_ db: Database, for interaction: Interaction, in thread: SessionThread) { owsFailDebug("") }