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
This commit is contained in:
Morgan Pretty 2022-05-26 18:13:16 +10:00
parent 19cd9d13c5
commit 62c886e764
15 changed files with 431 additions and 244 deletions

View File

@ -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 = "<group>"; };
FD848B8A283DC509000E298B /* PagedDatabaseObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PagedDatabaseObserver.swift; sourceTree = "<group>"; };
FD848B8C283E0B26000E298B /* MessageInputTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageInputTypes.swift; sourceTree = "<group>"; };
FD848B8E283EF2A8000E298B /* UIScrollView+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIScrollView+Utilities.swift"; sourceTree = "<group>"; };
FD859EFF27C4691300510D0C /* MockDataGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockDataGenerator.swift; sourceTree = "<group>"; };
FD88BAD827A7439C00BBC442 /* MessageRequestsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsCell.swift; sourceTree = "<group>"; };
FD88BADA27A750F200BBC442 /* MessageRequestsMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsMigration.swift; sourceTree = "<group>"; };
@ -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 */,

View File

@ -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 }

View File

@ -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
}
}
}

View File

@ -7,6 +7,10 @@ import SessionMessagingKit
import SessionUtilitiesKit
public class ConversationViewModel: OWSAudioPlayerDelegate {
public typealias SectionModel = ArraySection<Section, MessageCell.ViewModel>
// 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<MessageCell.AttachmentInteractionInfo, MessageCell.ViewModel>(
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
dataQuery: MessageCell.ViewModel.baseQuery(
orderSQL: MessageCell.ViewModel.orderSQL,
baseFilterSQL: MessageCell.ViewModel.filterSQL(threadId: threadId)
),
associatedRecords: [
AssociatedRecord<MessageCell.AttachmentInteractionInfo, MessageCell.ViewModel>(
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)
}
// If we haven't stored the data for the initial fetch then do so now (no need
// to call 'onInteractionsChange' in this case as it will always be null)
guard hasSavedIntialUpdate else {
self?.updateInteractionData(updatedInteractionData)
hasSavedIntialUpdate = true
return
}
self?.onInteractionChange?(updatedInteractionData)
}
)
)
}
}
// MARK: - Variables
@ -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<Interaction, MessageCell.ViewModel>?
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)
}

View File

@ -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
}

View File

@ -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: {}
)

View File

@ -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..<numSections)
.map { self.numberOfRows(inSection: $0) }
guard self.afterNextLayoutCondition?(numSections, numRowInSections) == true else { return }
self.afterNextLayoutCallback?()
self.afterNextLayoutCondition = nil
self.afterNextLayoutCallback = nil
}
}

View File

@ -26,6 +26,7 @@ class EmptySearchResultCell: UITableViewCell {
super.init(style: style, reuseIdentifier: reuseIdentifier)
backgroundColor = .clear
selectionStyle = .none
contentView.addSubview(messageLabel)
messageLabel.autoSetDimension(.height, toSize: 150)

View File

@ -9,13 +9,35 @@ import SessionUtilitiesKit
import SignalUtilitiesKit
class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSource {
private struct SearchResultSet {
let contactsAndGroups: [ConversationCell.ViewModel]
let messages: [ConversationCell.ViewModel]
fileprivate typealias SectionModel = ArraySection<SearchSection, ConversationCell.ViewModel>
// 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<SearchSection, ConversationCell.ViewModel>] = []
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<SearchResultSet, Error> 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<SearchSection, ConversationCell.ViewModel> = 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<SearchSection, ConversationCell.ViewModel> = self.searchResultSet[indexPath.section]
let section: SectionModel = self.searchResultSet[indexPath.section]
switch section.model {
case .noResults:

View File

@ -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<Attachment, Item>?
public private(set) var pagedDataObserver: PagedDatabaseObserver<Attachment, Item>?
/// 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

View File

@ -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
}

View File

@ -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()
}
)
}
}

View File

@ -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<SQLRequest<ConversationCell.ViewModel>> {
let thread: TypedTableAlias<SessionThread> = 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<ViewModel> = """
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

View File

@ -450,6 +450,7 @@ public class PagedDatabaseObserver<ObservedTable, T>: TransactionObserver where
if let updatedPageInfo: PagedData.PageInfo = loadedPage?.pageInfo {
self.pageInfo.mutate { $0 = updatedPageInfo }
}
self.isLoadingMoreData.mutate { $0 = false }
return
}

View File

@ -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)
}