From 62c886e764e741d229f77fe304c2bbb9d3cee761 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 26 May 2022 18:13:16 +1000 Subject: [PATCH] Got paging working on the conversation screen Fixed a couple of issues where attachment messages would flicker due to thread changing Fixed a couple of issues with page loading Connected the global search result select back up --- Session.xcodeproj/project.pbxproj | 4 + .../ConversationVC+Interaction.swift | 16 +- Session/Conversations/ConversationVC.swift | 46 ++-- .../Conversations/ConversationViewModel.swift | 170 +++++++++------ .../Content Views/MediaView.swift | 6 +- .../Content Views/QuoteView.swift | 12 +- .../InsetLockableTableView.swift | 38 +++- .../GlobalSearch/EmptySearchResultCell.swift | 1 + .../GlobalSearchViewController.swift | 198 ++++++++++-------- .../MediaGalleryViewModel.swift | 16 +- .../MediaTileViewController.swift | 75 +++---- .../Utilities/UIScrollView+Utilities.swift | 37 ++++ .../ConversationCellViewModel.swift | 51 ++++- .../Types/PagedDatabaseObserver.swift | 1 + SignalUtilitiesKit/Utilities/UIView+OWS.swift | 4 +- 15 files changed, 431 insertions(+), 244 deletions(-) create mode 100644 Session/Utilities/UIScrollView+Utilities.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 71ecea48f..59d4def34 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -685,6 +685,7 @@ FD848B87283B844B000E298B /* MessageCellViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B86283B844B000E298B /* MessageCellViewModel.swift */; }; FD848B8B283DC509000E298B /* PagedDatabaseObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B8A283DC509000E298B /* PagedDatabaseObserver.swift */; }; FD848B8D283E0B26000E298B /* MessageInputTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B8C283E0B26000E298B /* MessageInputTypes.swift */; }; + FD848B8F283EF2A8000E298B /* UIScrollView+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B8E283EF2A8000E298B /* UIScrollView+Utilities.swift */; }; FD859F0027C4691300510D0C /* MockDataGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EFF27C4691300510D0C /* MockDataGenerator.swift */; }; FD88BAD927A7439C00BBC442 /* MessageRequestsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD88BAD827A7439C00BBC442 /* MessageRequestsCell.swift */; }; FD88BADB27A750F200BBC442 /* MessageRequestsMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD88BADA27A750F200BBC442 /* MessageRequestsMigration.swift */; }; @@ -1661,6 +1662,7 @@ FD848B86283B844B000E298B /* MessageCellViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageCellViewModel.swift; sourceTree = ""; }; FD848B8A283DC509000E298B /* PagedDatabaseObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PagedDatabaseObserver.swift; sourceTree = ""; }; FD848B8C283E0B26000E298B /* MessageInputTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageInputTypes.swift; sourceTree = ""; }; + FD848B8E283EF2A8000E298B /* UIScrollView+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIScrollView+Utilities.swift"; sourceTree = ""; }; FD859EFF27C4691300510D0C /* MockDataGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockDataGenerator.swift; sourceTree = ""; }; FD88BAD827A7439C00BBC442 /* MessageRequestsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsCell.swift; sourceTree = ""; }; FD88BADA27A750F200BBC442 /* MessageRequestsMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsMigration.swift; sourceTree = ""; }; @@ -1935,6 +1937,7 @@ B8783E9D23EB948D00404FB8 /* UILabel+Interaction.swift */, B83F2B87240CB75A000A54AB /* UIImage+Scaling.swift */, FD1C98E3282E3C5B00B76F9E /* UINavigationBar+Utilities.swift */, + FD848B8E283EF2A8000E298B /* UIScrollView+Utilities.swift */, C31A6C59247F214E001123EF /* UIView+Glow.swift */, C3548F0724456AB6009433A8 /* UIView+Wrapping.swift */, FD859EFF27C4691300510D0C /* MockDataGenerator.swift */, @@ -4705,6 +4708,7 @@ C3E5C2FA251DBABB0040DFFC /* EditClosedGroupVC.swift in Sources */, B8783E9E23EB948D00404FB8 /* UILabel+Interaction.swift in Sources */, B893063F2383961A005EAA8E /* ScanQRCodeWrapperVC.swift in Sources */, + FD848B8F283EF2A8000E298B /* UIScrollView+Utilities.swift in Sources */, B879D449247E1BE300DB3608 /* PathVC.swift in Sources */, 454A84042059C787008B8C75 /* MediaTileViewController.swift in Sources */, 34D1F0871F8678AA0066283D /* ConversationViewItem.m in Sources */, diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 755ba8991..d468d0669 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -632,8 +632,12 @@ extension ConversationVC: // Show the context menu if applicable guard let keyWindow: UIWindow = UIApplication.shared.keyWindow, - let index = viewModel.interactionData.firstIndex(of: cellViewModel), - let cell = tableView.cellForRow(at: IndexPath(row: index, section: 0)) as? VisibleMessageCell, + let sectionIndex: Int = self.viewModel.interactionData + .firstIndex(where: { $0.model == .messages }), + let index = self.viewModel.interactionData[sectionIndex] + .elements + .firstIndex(of: cellViewModel), + let cell = tableView.cellForRow(at: IndexPath(row: index, section: sectionIndex)) as? VisibleMessageCell, let snapshot = cell.bubbleView.snapshotView(afterScreenUpdates: false), contextMenuWindow == nil, let actions: [ContextMenuVC.Action] = ContextMenuVC.actions( @@ -693,8 +697,12 @@ extension ConversationVC: case .mediaMessage: guard - let index = self.viewModel.interactionData.firstIndex(where: { $0.id == cellViewModel.id }), - let cell = tableView.cellForRow(at: IndexPath(row: index, section: 0)) as? VisibleMessageCell, + let sectionIndex: Int = self.viewModel.interactionData + .firstIndex(where: { $0.model == .messages }), + let messageIndex: Int = self.viewModel.interactionData[sectionIndex] + .elements + .firstIndex(where: { $0.id == cellViewModel.id }), + let cell = tableView.cellForRow(at: IndexPath(row: messageIndex, section: sectionIndex)) as? VisibleMessageCell, let albumView: MediaAlbumView = cell.albumView else { return } diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index 69c5b293e..40323d700 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -937,12 +937,20 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers } func scrollToBottom(isAnimated: Bool) { - guard !isUserScrolling && !viewModel.interactionData.isEmpty else { return } + guard + !isUserScrolling, + let messagesSectionIndex: Int = self.viewModel.interactionData + .firstIndex(where: { $0.model == .messages }), + !self.viewModel.interactionData[messagesSectionIndex] + .elements + .isEmpty + else { return } tableView.scrollToRow( at: IndexPath( - row: viewModel.interactionData.count - 1, - section: 0), + row: viewModel.interactionData[messagesSectionIndex].elements.count - 1, + section: messagesSectionIndex + ), at: .bottom, animated: isAnimated ) @@ -959,7 +967,6 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers func scrollViewDidScroll(_ scrollView: UIScrollView) { scrollButton.alpha = getScrollButtonOpacity() unreadCountView.alpha = scrollButton.alpha - autoLoadMoreIfNeeded() } func updateUnreadCountView(unreadCount: UInt?) { @@ -970,14 +977,6 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers unreadCountView.isHidden = (unreadCount == 0) } - func autoLoadMoreIfNeeded() { - let isMainAppAndActive = CurrentAppContext().isMainAppAndActive - guard isMainAppAndActive && didFinishInitialLayout && viewModel.canLoadMoreItems() && !isLoadingMore - && messagesTableView.contentOffset.y < ConversationVC.loadMoreThreshold else { return } - isLoadingMore = true - viewModel.loadAnotherPageOfMessages() - } - func getScrollButtonOpacity() -> CGFloat { let contentOffsetY = tableView.contentOffset.y let x = (lastPageTop - ConversationVC.bottomInset - contentOffsetY).clamp(0, .greatestFiniteMagnitude) @@ -1078,5 +1077,28 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers isAnimated: Bool = true, highlighted: Bool = false ) { + // Ensure the interaction is loaded + self.viewModel.pagedDataObserver?.load(.untilInclusive(id: interactionId, padding: 0)) + + guard + let messageSectionIndex: Int = self.viewModel.interactionData + .firstIndex(where: { $0.model == .messages }), + let targetMessageIndex = self.viewModel.interactionData[messageSectionIndex] + .elements + .firstIndex(where: { $0.id == interactionId }) + else { return } + + tableView.scrollToRow( + at: IndexPath( + row: targetMessageIndex, + section: messageSectionIndex + ), + at: position, + animated: isAnimated + ) + + if highlighted { + focusedMessageId = interactionId + } } } diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index 7ef7ef60b..f6c1c89ea 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -7,6 +7,10 @@ import SessionMessagingKit import SessionUtilitiesKit public class ConversationViewModel: OWSAudioPlayerDelegate { + public typealias SectionModel = ArraySection + + // MARK: - Action + public enum Action { case none case compose @@ -15,6 +19,16 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { } public static let pageSize: Int = 50 + // MARK: - Section + + public enum Section: Differentiable, Equatable, Comparable, Hashable { + case loadOlder + case messages + case loadNewer + } + + // MARK: - Variables + // MARK: - Initialization @@ -34,58 +48,52 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { self.threadId = threadId self.threadData = threadData - self.focusedInteractionId = focusedInteractionId // TODO: This + self.focusedInteractionId = focusedInteractionId self.pagedDataObserver = nil - var hasSavedIntialUpdate: Bool = false - self.pagedDataObserver = PagedDatabaseObserver( - pagedTable: Interaction.self, - pageSize: ConversationViewModel.pageSize, - idColumn: .id, - initialFocusedId: nil, - observedChanges: [ - PagedData.ObservedChanges( - table: Interaction.self, - columns: Interaction.Columns - .allCases - .filter { $0 != .wasRead } - ) - ], - filterSQL: MessageCell.ViewModel.filterSQL(threadId: threadId), - orderSQL: MessageCell.ViewModel.orderSQL, - dataQuery: MessageCell.ViewModel.baseQuery( + + DispatchQueue.global(qos: .default).async { [weak self] in + self?.pagedDataObserver = PagedDatabaseObserver( + pagedTable: Interaction.self, + pageSize: ConversationViewModel.pageSize, + idColumn: .id, + initialFocusedId: focusedInteractionId, + observedChanges: [ + PagedData.ObservedChanges( + table: Interaction.self, + columns: Interaction.Columns + .allCases + .filter { $0 != .wasRead } + ) + ], + filterSQL: MessageCell.ViewModel.filterSQL(threadId: threadId), orderSQL: MessageCell.ViewModel.orderSQL, - baseFilterSQL: MessageCell.ViewModel.filterSQL(threadId: threadId) - ), - associatedRecords: [ - AssociatedRecord( - trackedAgainst: Attachment.self, - observedChanges: [ - PagedData.ObservedChanges( - table: Attachment.self, - columns: [.state] - ) - ], - dataQuery: MessageCell.AttachmentInteractionInfo.baseQuery, - joinToPagedType: MessageCell.AttachmentInteractionInfo.joinToViewModelQuerySQL, - associateData: MessageCell.AttachmentInteractionInfo.createAssociateDataClosure() - ) - ], - onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in - guard let updatedInteractionData: [MessageCell.ViewModel] = self?.process(data: updatedData, for: updatedPageInfo) else { - return - } - - // If we haven't stored the data for the initial fetch then do so now (no need - // to call 'onInteractionsChange' in this case as it will always be null) - guard hasSavedIntialUpdate else { - self?.updateInteractionData(updatedInteractionData) - hasSavedIntialUpdate = true - return + dataQuery: MessageCell.ViewModel.baseQuery( + orderSQL: MessageCell.ViewModel.orderSQL, + baseFilterSQL: MessageCell.ViewModel.filterSQL(threadId: threadId) + ), + associatedRecords: [ + AssociatedRecord( + trackedAgainst: Attachment.self, + observedChanges: [ + PagedData.ObservedChanges( + table: Attachment.self, + columns: [.state] + ) + ], + dataQuery: MessageCell.AttachmentInteractionInfo.baseQuery, + joinToPagedType: MessageCell.AttachmentInteractionInfo.joinToViewModelQuerySQL, + associateData: MessageCell.AttachmentInteractionInfo.createAssociateDataClosure() + ) + ], + onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in + guard let updatedInteractionData: [SectionModel] = self?.process(data: updatedData, for: updatedPageInfo) else { + return + } + + self?.onInteractionChange?(updatedInteractionData) } - - self?.onInteractionChange?(updatedInteractionData) - } - ) + ) + } } // MARK: - Variables @@ -130,29 +138,44 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { // MARK: - Interaction Data - public private(set) var interactionData: [MessageCell.ViewModel] = [] + public private(set) var interactionData: [SectionModel] = [] public private(set) var pagedDataObserver: PagedDatabaseObserver? - public var onInteractionChange: (([MessageCell.ViewModel]) -> ())? + public var onInteractionChange: (([SectionModel]) -> ())? - private func process(data: [MessageCell.ViewModel], for pageInfo: PagedData.PageInfo) -> [MessageCell.ViewModel] { + private func process(data: [MessageCell.ViewModel], for pageInfo: PagedData.PageInfo) -> [SectionModel] { let sortedData: [MessageCell.ViewModel] = data .sorted { lhs, rhs -> Bool in lhs.timestampMs < rhs.timestampMs } - return sortedData - .enumerated() - .map { index, cellViewModel -> MessageCell.ViewModel in - cellViewModel.withClusteringChanges( - prevModel: (index > 0 ? sortedData[index - 1] : nil), - nextModel: (index < (sortedData.count - 2) ? sortedData[index + 1] : nil), - isLast: ( - index == (sortedData.count - 1) && - pageInfo.currentCount == pageInfo.totalCount - ) + return [ + (!data.isEmpty && (pageInfo.pageOffset + pageInfo.currentCount) < pageInfo.totalCount ? + [SectionModel(section: .loadOlder)] : + [] + ), + [ + SectionModel( + section: .messages, + elements: sortedData + .enumerated() + .map { index, cellViewModel -> MessageCell.ViewModel in + cellViewModel.withClusteringChanges( + prevModel: (index > 0 ? sortedData[index - 1] : nil), + nextModel: (index < (sortedData.count - 2) ? sortedData[index + 1] : nil), + isLast: ( + index == (sortedData.count - 1) && + pageInfo.currentCount == pageInfo.totalCount + ) + ) + } ) - } + ], + (data.isEmpty && pageInfo.pageOffset > 0 ? + [SectionModel(section: .loadNewer)] : + [] + ) + ].flatMap { $0 } } - public func updateInteractionData(_ updatedData: [MessageCell.ViewModel]) { + public func updateInteractionData(_ updatedData: [SectionModel]) { self.interactionData = updatedData } @@ -288,7 +311,13 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { } public func markAllAsRead() { - guard let lastInteractionId: Int64 = self.interactionData.last?.id else { return } + guard + let lastInteractionId: Int64 = self.interactionData + .first(where: { $0.model == .messages })? + .elements + .last? + .id + else { return } GRDBStorage.shared.write { db in try Interaction.markAsRead( @@ -487,12 +516,15 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { // If the next interaction is another voice message then autoplay it guard - let currentIndex: Int = self.interactionData.firstIndex(where: { $0.id == interactionId }), - currentIndex < (self.interactionData.count - 1), - self.interactionData[currentIndex + 1].cellType == .audio + let messageSection: SectionModel = self.interactionData + .first(where: { $0.model == .messages }), + let currentIndex: Int = messageSection.elements + .firstIndex(where: { $0.id == interactionId }), + currentIndex < (messageSection.elements.count - 1), + messageSection.elements[currentIndex + 1].cellType == .audio else { return } - let nextItem: MessageCell.ViewModel = self.interactionData[currentIndex + 1] + let nextItem: MessageCell.ViewModel = messageSection.elements[currentIndex + 1] playOrPauseAudio(for: nextItem) } diff --git a/Session/Conversations/Message Cells/Content Views/MediaView.swift b/Session/Conversations/Message Cells/Content Views/MediaView.swift index 7185a076b..9faee9f5d 100644 --- a/Session/Conversations/Message Cells/Content Views/MediaView.swift +++ b/Session/Conversations/Message Cells/Content Views/MediaView.swift @@ -173,7 +173,7 @@ public class MediaView: UIView { owsFailDebug("Media has unexpected type: \(type(of: media))") return } - + // FIXME: Animated images flicker when reloading the cells (even though they are in the cache) animatedImageView.image = image }, cacheKey: attachment.id @@ -365,9 +365,9 @@ public class MediaView: UIView { if let media: AnyObject = self.mediaCache.object(forKey: cacheKey as NSString) { Logger.verbose("media cache hit") - guard !Thread.isMainThread else { + guard Thread.isMainThread else { DispatchQueue.main.async { - loadMediaBlock(loadCompletion) + loadCompletion(media) } return } diff --git a/Session/Conversations/Message Cells/Content Views/QuoteView.swift b/Session/Conversations/Message Cells/Content Views/QuoteView.swift index ddeb9ae33..b5db4cd45 100644 --- a/Session/Conversations/Message Cells/Content Views/QuoteView.swift +++ b/Session/Conversations/Message Cells/Content Views/QuoteView.swift @@ -136,10 +136,16 @@ final class QuoteView: UIView { attachment.thumbnail( size: .small, success: { image, _ in - DispatchQueue.main.async { - imageView.image = image - imageView.contentMode = .scaleAspectFill + guard Thread.isMainThread else { + DispatchQueue.main.async { + imageView.image = image + imageView.contentMode = .scaleAspectFill + } + return } + + imageView.image = image + imageView.contentMode = .scaleAspectFill }, failure: {} ) diff --git a/Session/Conversations/Views & Modals/InsetLockableTableView.swift b/Session/Conversations/Views & Modals/InsetLockableTableView.swift index b724eeb82..9fd38c50e 100644 --- a/Session/Conversations/Views & Modals/InsetLockableTableView.swift +++ b/Session/Conversations/Views & Modals/InsetLockableTableView.swift @@ -2,9 +2,14 @@ import UIKit -/// This custom UITableView allows us to lock the contentOffset to a specific value - it's current used to prevent -/// the ConversationVC first responder resignation from making the MediaGalleryDetailViewController transition -/// from looking buggy (ie. the table scrolls down with the resignation during the transition) +/// This custom UITableView gives us two convenience behaviours: +/// +/// 1. It allows us to lock the contentOffset to a specific value - it's currently used to prevent the ConversationVC first +/// responder resignation from making the MediaGalleryDetailViewController transition from looking buggy (ie. the table +/// scrolls down with the resignation during the transition) +/// +/// 2. It allows us to provode a callback which gets triggered if a condition closure returns true - it's currently used to prevent +/// the table view from jumping when inserting new pages at the top of a conversation screen public class InsetLockableTableView: UITableView { public var lockContentOffset: Bool = false { didSet { @@ -15,6 +20,8 @@ public class InsetLockableTableView: UITableView { } public var oldOffset: CGPoint = .zero public var newOffset: CGPoint = .zero + private var afterNextLayoutCondition: ((Int, [Int]) -> Bool)? + private var afterNextLayoutCallback: (() -> ())? public override func layoutSubviews() { newOffset = self.contentOffset @@ -24,12 +31,35 @@ public class InsetLockableTableView: UITableView { x: newOffset.x, y: oldOffset.y ) + super.layoutSubviews() + + self.performNextLayoutCallbackIfPossible() return } super.layoutSubviews() - oldOffset = self.contentOffset + self.performNextLayoutCallbackIfPossible() + self.oldOffset = self.contentOffset + } + + // MARK: - Function + + public func afterNextLayout(when condition: @escaping (Int, [Int]) -> Bool, then callback: @escaping () -> ()) { + self.afterNextLayoutCondition = condition + self.afterNextLayoutCallback = callback + } + + private func performNextLayoutCallbackIfPossible() { + let numSections: Int = self.numberOfSections + let numRowInSections: [Int] = (0.. + + // MARK: - SearchSection + + enum SearchSection: Int, Differentiable { + case noResults + case contactsAndGroups + case messages } - let isRecentSearchResultsEnabled = false - + // MARK: - Variables + + private lazy var defaultSearchResults: [SectionModel] = { + let result: ConversationCell.ViewModel? = GRDBStorage.shared.read { db -> ConversationCell.ViewModel? in + try ConversationCell.ViewModel + .noteToSelfOnlyQuery(userPublicKey: getUserHexEncodedPublicKey(db)) + .fetchOne(db) + } + + return [ result.map { ArraySection(model: .contactsAndGroups, elements: [$0]) } ] + .compactMap { $0 } + }() + private lazy var searchResultSet: [SectionModel] = self.defaultSearchResults + private var termForCurrentSearchResultSet: String = "" + private var lastSearchText: String? + private var refreshTimer: Timer? + + var isLoading = false + @objc public var searchText = "" { didSet { AssertIsOnMainThread() @@ -23,23 +45,6 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo refreshSearchResults() } } - var defaultSearchResults: HomeScreenSearchResultSet = HomeScreenSearchResultSet.noteToSelfOnly - - var searchResultSet: [ArraySection] = [] - private var termForCurrentSearchResultSet: String = "" - - - private var lastSearchText: String? - var searcher: FullTextSearcher { - return FullTextSearcher.shared - } - var isLoading = false - - enum SearchSection: Int, Differentiable { - case noResults - case contactsAndGroups - case messages - } // MARK: - UI Components @@ -114,8 +119,6 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo // MARK: - Update Search Results - var refreshTimer: Timer? - private func refreshSearchResults() { refreshTimer?.invalidate() refreshTimer = WeakTimer.scheduledTimer(timeInterval: 0.1, target: self, userInfo: nil, repeats: false) { [weak self] _ in @@ -136,49 +139,55 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo lastSearchText = searchText - GRDBStorage.shared - .read { db -> Result in - do { - let contactsAndGroupsResults: [ConversationCell.ViewModel] = try ConversationCell.ViewModel - .contactsAndGroupsQuery( - userPublicKey: getUserHexEncodedPublicKey(db), - pattern: try ConversationCell.ViewModel.pattern(db, searchTerm: searchText), - searchTerm: searchText - ) - .fetchAll(db) - - let messageResults: [ConversationCell.ViewModel] = try ConversationCell.ViewModel - .messagesQuery( - userPublicKey: getUserHexEncodedPublicKey(db), - pattern: try ConversationCell.ViewModel.pattern(db, searchTerm: searchText) - ) - .fetchAll(db) - - return .success(SearchResultSet( - contactsAndGroups: contactsAndGroupsResults, - messages: messageResults - )) - } - catch { - return .failure(error) - } + let result: Result<[SectionModel], Error>? = GRDBStorage.shared.read { db -> Result<[SectionModel], Error> in + do { + let userPublicKey: String = getUserHexEncodedPublicKey(db) + + let contactsAndGroupsResults: [ConversationCell.ViewModel] = try ConversationCell.ViewModel + .contactsAndGroupsQuery( + userPublicKey: userPublicKey, + pattern: try ConversationCell.ViewModel.pattern(db, searchTerm: searchText), + searchTerm: searchText + ) + .fetchAll(db) + + let messageResults: [ConversationCell.ViewModel] = try ConversationCell.ViewModel + .messagesQuery( + userPublicKey: userPublicKey, + pattern: try ConversationCell.ViewModel.pattern(db, searchTerm: searchText) + ) + .fetchAll(db) + + return .success([ + ArraySection(model: .contactsAndGroups, elements: contactsAndGroupsResults), + ArraySection(model: .messages, elements: messageResults) + ]) } - .map { [weak self] result in - switch result { - case .success(let resultSet): - self?.termForCurrentSearchResultSet = searchText - self?.searchResultSet = [ - ArraySection(model: .contactsAndGroups, elements: resultSet.contactsAndGroups), - ArraySection(model: .messages, elements: resultSet.messages) - ] - self?.isLoading = false - self?.reloadTableData() - self?.refreshTimer = nil - - - case .failure: break - } + catch { + return .failure(error) } + } + + switch result { + case .success(let sections): + let hasResults: Bool = ( + !searchText.isEmpty && + (sections.map { $0.elements.count }.reduce(0, +) > 0) + ) + + self.termForCurrentSearchResultSet = searchText + self.searchResultSet = [ + (hasResults ? nil : [ArraySection(model: .noResults, elements: [ConversationCell.ViewModel(unreadCount: 0)])]), + (hasResults ? sections : nil) + ] + .compactMap { $0 } + .flatMap { $0 } + self.isLoading = false + self.reloadTableData() + self.refreshTimer = nil + + default: break + } } } @@ -218,30 +227,40 @@ extension GlobalSearchViewController { public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: false) - guard let searchSection = SearchSection(rawValue: indexPath.section) else { return } + let section: SectionModel = self.searchResultSet[indexPath.section] - switch searchSection { - case .noResults: - SNLog("shouldn't be able to tap 'no results' section") - - case .contactsAndGroups: - break - - case .messages: - break + switch section.model { + case .noResults: break + case .contactsAndGroups, .messages: + show( + threadId: section.elements[indexPath.row].threadId, + focusedInteractionId: section.elements[indexPath.row].interactionId + ) } } - private func show(_ thread: TSThread, highlightedMessageID: String?, animated: Bool, isFromRecent: Bool = false) { - if let presentedVC = self.presentedViewController { - presentedVC.dismiss(animated: false, completion: nil) + private func show(threadId: String, focusedInteractionId: Int64? = nil, animated: Bool = true) { + guard Thread.isMainThread else { + DispatchQueue.main.async { [weak self] in + self?.show(threadId: threadId, focusedInteractionId: focusedInteractionId, animated: animated) } - let conversationVC = ConversationVC(thread: thread, focusedMessageID: highlightedMessageID) - var viewControllers = self.navigationController?.viewControllers - if isFromRecent, let index = viewControllers?.firstIndex(of: self) { viewControllers?.remove(at: index) } - viewControllers?.append(conversationVC) - self.navigationController?.setViewControllers(viewControllers!, animated: true) + return + } + + guard let conversationVC: ConversationVC = ConversationVC(threadId: threadId, focusedInteractionId: focusedInteractionId) else { + return + } + + if let presentedVC = self.presentedViewController { + presentedVC.dismiss(animated: false, completion: nil) } + + let viewControllers: [UIViewController] = (self.navigationController? + .viewControllers) + .defaulting(to: []) + .appending(conversationVC) + + self.navigationController?.setViewControllers(viewControllers, animated: true) } // MARK: - UITableViewDataSource @@ -249,6 +268,10 @@ extension GlobalSearchViewController { public func numberOfSections(in tableView: UITableView) -> Int { return self.searchResultSet.count } + + public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return self.searchResultSet[section].elements.count + } public func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { UIView() @@ -286,7 +309,8 @@ extension GlobalSearchViewController { } public func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { - let section: ArraySection = self.searchResultSet[section] + let section: SectionModel = self.searchResultSet[section] + switch section.model { case .noResults: return nil case .contactsAndGroups: return (section.elements.isEmpty ? nil : "SEARCH_SECTION_CONTACTS".localized()) @@ -294,16 +318,12 @@ extension GlobalSearchViewController { } } - public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return self.searchResultSet[section].elements.count - } - public func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { return UITableView.automaticDimension } public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let section: ArraySection = self.searchResultSet[indexPath.section] + let section: SectionModel = self.searchResultSet[indexPath.section] switch section.model { case .noResults: diff --git a/Session/Media Viewing & Editing/MediaGalleryViewModel.swift b/Session/Media Viewing & Editing/MediaGalleryViewModel.swift index 65aa4c09c..d54c1d919 100644 --- a/Session/Media Viewing & Editing/MediaGalleryViewModel.swift +++ b/Session/Media Viewing & Editing/MediaGalleryViewModel.swift @@ -18,6 +18,8 @@ public class MediaGalleryViewModel { case loadNewer } + // MARK: - Variables + public let threadId: String public let threadVariant: SessionThread.Variant private var focusedAttachmentId: String? @@ -30,7 +32,7 @@ public class MediaGalleryViewModel { public var interactionIdBefore: [Int64: Int64] { cachedInteractionIdBefore.wrappedValue } public var interactionIdAfter: [Int64: Int64] { cachedInteractionIdAfter.wrappedValue } public private(set) var albumData: [Int64: [Item]] = [:] - public private(set) var pagedDatabaseObserver: PagedDatabaseObserver? + public private(set) var pagedDataObserver: PagedDatabaseObserver? /// This value is the current state of a gallery view public private(set) var galleryData: [SectionModel] = [] @@ -48,13 +50,13 @@ public class MediaGalleryViewModel { self.threadId = threadId self.threadVariant = threadVariant self.focusedAttachmentId = focusedAttachmentId - self.pagedDatabaseObserver = nil + self.pagedDataObserver = nil guard isPagedData else { return } var hasSavedIntialUpdate: Bool = false let filterSQL: SQL = Item.filterSQL(threadId: threadId) - self.pagedDatabaseObserver = PagedDatabaseObserver( + self.pagedDataObserver = PagedDatabaseObserver( pagedTable: Attachment.self, pageSize: pageSize, idColumn: .id, @@ -433,14 +435,6 @@ public class MediaGalleryViewModel { } } - public func loadNewerGalleryItems() { - self.pagedDatabaseObserver?.load(.pageBefore) - } - - public func loadOlderGalleryItems() { - self.pagedDatabaseObserver?.load(.pageAfter) - } - public func updateFocusedItem(attachmentId: String, indexPath: IndexPath) { // Note: We need to set both of these as the 'focusedIndexPath' is usually // derived and if the data changes it will be regenerated using the diff --git a/Session/Media Viewing & Editing/MediaTileViewController.swift b/Session/Media Viewing & Editing/MediaTileViewController.swift index 8f2aac326..2e42d3676 100644 --- a/Session/Media Viewing & Editing/MediaTileViewController.swift +++ b/Session/Media Viewing & Editing/MediaTileViewController.swift @@ -20,6 +20,7 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour private let viewModel: MediaGalleryViewModel private var hasLoadedInitialData: Bool = false + private var isAutoLoadingNextPage: Bool = false private var currentTargetOffset: CGPoint? var isInBatchSelectMode = false { @@ -34,7 +35,7 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour init(viewModel: MediaGalleryViewModel) { self.viewModel = viewModel - GRDBStorage.shared.addObserver(viewModel.pagedDatabaseObserver) + GRDBStorage.shared.addObserver(viewModel.pagedDataObserver) super.init(nibName: nil, bundle: nil) } @@ -212,20 +213,30 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour } private func autoLoadNextPageIfNeeded() { + guard !self.isAutoLoadingNextPage else { return } + + self.isAutoLoadingNextPage = true + DispatchQueue.main.asyncAfter(deadline: .now() + MediaTileViewController.autoLoadNextPageDelay) { [weak self] in + self?.isAutoLoadingNextPage = false + + // Note: We sort the headers as we want to prioritise loading newer pages over older ones let sortedVisibleIndexPaths: [IndexPath] = (self?.collectionView .indexPathsForVisibleSupplementaryElements(ofKind: UICollectionView.elementKindSectionHeader)) .defaulting(to: []) .sorted() for headerIndexPath in sortedVisibleIndexPaths { - switch self?.viewModel.galleryData[safe: headerIndexPath.section]?.model { - case .loadNewer: - self?.viewModel.loadNewerGalleryItems() - return - - case .loadOlder: - self?.viewModel.loadOlderGalleryItems() + let section: MediaGalleryViewModel.SectionModel? = self?.viewModel.galleryData[safe: headerIndexPath.section] + + switch section?.model { + case .loadNewer, .loadOlder: + // Attachments are loaded in descending order so 'loadOlder' actually corresponds with + // 'pageAfter' in this case + self?.viewModel.pagedDataObserver?.load(section?.model == .loadOlder ? + .pageAfter : + .pageBefore + ) return default: continue @@ -242,7 +253,7 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour } private func stopObservingChanges() { - // Note: The 'PagedDatabaseObserver' will continue to get changes but + // Note: The 'pagedDataObserver' will continue to get changes but // we don't want to trigger any UI updates self.viewModel.onGalleryChange = nil } @@ -261,8 +272,8 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour // Determine if we are inserting content at the top of the collectionView let isInsertingAtTop: Bool = { let oldFirstSectionIsLoadMore: Bool = ( - self.viewModel.galleryData[safe: 0]?.model == .loadNewer || - self.viewModel.galleryData[safe: 0]?.model == .loadOlder + self.viewModel.galleryData.first?.model == .loadNewer || + self.viewModel.galleryData.first?.model == .loadOlder ) let oldTargetSectionIndex: Int = (oldFirstSectionIsLoadMore ? 1 : 0) @@ -399,41 +410,17 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour guard self.hasLoadedInitialData else { return } let section: MediaGalleryViewModel.SectionModel = self.viewModel.galleryData[indexPath.section] - let fastEndScrollingThen: ((@escaping () -> ()) -> ()) = { callback in - let endOffset: CGPoint - - if let currentTargetOffset: CGPoint = self.currentTargetOffset { - endOffset = currentTargetOffset - } - else { - let currentVelocity: CGPoint = collectionView.panGestureRecognizer.velocity(in: collectionView) - - endOffset = CGPoint( - x: collectionView.contentOffset.x, - y: collectionView.contentOffset.y - (currentVelocity.y / 100) - ) - } - - guard endOffset != collectionView.contentOffset else { - return callback() - } - - UIView.animate( - withDuration: 0.1, - delay: 0, - options: .curveEaseOut, - animations: { - collectionView.setContentOffset(endOffset, animated: false) - }, - completion: { _ in - callback() - } - ) - } switch section.model { - case .loadOlder: fastEndScrollingThen { self.viewModel.loadOlderGalleryItems() } - case .loadNewer: fastEndScrollingThen { self.viewModel.loadNewerGalleryItems() } + case .loadOlder, .loadNewer: + UIScrollView.fastEndScrollingThen(collectionView, self.currentTargetOffset) { [weak self] in + // Attachments are loaded in descending order so 'loadOlder' actually corresponds with + // 'pageAfter' in this case + self?.viewModel.pagedDataObserver?.load(section.model == .loadOlder ? + .pageAfter : + .pageBefore + ) + } case .emptyGallery, .galleryMonth: break } diff --git a/Session/Utilities/UIScrollView+Utilities.swift b/Session/Utilities/UIScrollView+Utilities.swift new file mode 100644 index 000000000..e72f27d2d --- /dev/null +++ b/Session/Utilities/UIScrollView+Utilities.swift @@ -0,0 +1,37 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit + +public extension UIScrollView { + static let fastEndScrollingThen: ((UIScrollView, CGPoint?, @escaping () -> ()) -> ()) = { scrollView, currentTargetOffset, callback in + let endOffset: CGPoint + + if let currentTargetOffset: CGPoint = currentTargetOffset { + endOffset = currentTargetOffset + } + else { + let currentVelocity: CGPoint = scrollView.panGestureRecognizer.velocity(in: scrollView) + + endOffset = CGPoint( + x: scrollView.contentOffset.x, + y: scrollView.contentOffset.y - (currentVelocity.y / 100) + ) + } + + guard endOffset != scrollView.contentOffset else { + return callback() + } + + UIView.animate( + withDuration: 0.1, + delay: 0, + options: .curveEaseOut, + animations: { + scrollView.setContentOffset(endOffset, animated: false) + }, + completion: { _ in + callback() + } + ) + } +} diff --git a/SessionMessagingKit/Shared Models/ConversationCellViewModel.swift b/SessionMessagingKit/Shared Models/ConversationCellViewModel.swift index 7d9b6d93a..2f457b6b7 100644 --- a/SessionMessagingKit/Shared Models/ConversationCellViewModel.swift +++ b/SessionMessagingKit/Shared Models/ConversationCellViewModel.swift @@ -198,9 +198,9 @@ extension ConversationCell { // MARK: - Convenience Initialization public extension ConversationCell.ViewModel { - // Note: This init method is only used for the message requests cell on the home screen so we can avoid having - init(unreadCount: UInt) { - self.threadId = "UNREAD_MESSAGE_REQUEST_THREADS" + // Note: This init method is only used for the message requests cell or empty states + init(unreadCount: UInt = 0) { + self.threadId = "INVALID_THREAD_ID" self.threadVariant = .contact self.threadCreationDateTimestamp = 0 self.threadMemberNames = nil @@ -1175,6 +1175,51 @@ public extension ConversationCell.ViewModel { ]) } } + + /// This method returns only the 'Note to Self' thread in the structure of a search result conversation + static func noteToSelfOnlyQuery(userPublicKey: String) -> AdaptedFetchRequest> { + let thread: TypedTableAlias = TypedTableAlias() + let profileIdColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.id.name) + + /// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before + /// the `ViewModel.contactProfileKey` entry below otherwise the query will fail to + /// parse and might throw + let numColumnsBeforeProfiles: Int = 7 + let request: SQLRequest = """ + SELECT + 100 AS \(Column.rank), + + \(thread[.id]) AS \(ViewModel.threadIdKey), + \(thread[.variant]) AS \(ViewModel.threadVariantKey), + \(thread[.creationDateTimestamp]) AS \(ViewModel.threadCreationDateTimestampKey), + '' AS \(ViewModel.threadMemberNamesKey), + + true AS \(ViewModel.threadIsNoteToSelfKey), + \(thread[.isPinned]) AS \(ViewModel.threadIsPinnedKey), + + \(ViewModel.contactProfileKey).*, + + \(SQL("\(userPublicKey)")) AS \(ViewModel.currentUserPublicKeyKey) + + FROM \(SessionThread.self) + JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON \(ViewModel.contactProfileKey).\(profileIdColumnLiteral) = \(thread[.id]) + + WHERE \(SQL("\(thread[.id]) = \(userPublicKey)")) + """ + + // Add adapters which will group the various 'Profile' columns so they can be decoded + // as instances of 'Profile' types + return request.adapted { db in + let adapters = try splittingRowAdapters(columnCounts: [ + numColumnsBeforeProfiles, + Profile.numberOfSelectedColumns(db) + ]) + + return ScopeAdapter([ + ViewModel.contactProfileString: adapters[1] + ]) + } + } } // MARK: - Share Extension diff --git a/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift b/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift index 46fa83059..c0d4ac419 100644 --- a/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift +++ b/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift @@ -450,6 +450,7 @@ public class PagedDatabaseObserver: TransactionObserver where if let updatedPageInfo: PagedData.PageInfo = loadedPage?.pageInfo { self.pageInfo.mutate { $0 = updatedPageInfo } } + self.isLoadingMoreData.mutate { $0 = false } return } diff --git a/SignalUtilitiesKit/Utilities/UIView+OWS.swift b/SignalUtilitiesKit/Utilities/UIView+OWS.swift index 2a8f2c736..34f2227a6 100644 --- a/SignalUtilitiesKit/Utilities/UIView+OWS.swift +++ b/SignalUtilitiesKit/Utilities/UIView+OWS.swift @@ -137,7 +137,7 @@ public extension UIViewController { } func presentAlert(_ alert: UIAlertController, animated: Bool) { - if !Thread.isMainThread { + guard Thread.isMainThread else { DispatchQueue.main.async { [weak self] in self?.presentAlert(alert, animated: animated) } @@ -150,7 +150,7 @@ public extension UIViewController { } func presentAlert(_ alert: UIAlertController, completion: @escaping (() -> Void)) { - if !Thread.isMainThread { + guard Thread.isMainThread else { DispatchQueue.main.async { [weak self] in self?.presentAlert(alert, completion: completion) }