Browse Source

Added paging to the Home/MessageRequests screens and fix a bunch of bugs

Added a cache to the Identicon to prevent unneeded image generation
Replaced some 'withTint' calls to use the standard 'withRenderingMode' instead
Fixed a bug where the background would remain when swiping to reply
Fixed a crash which could occur with String-based settings
Fixed an issue where all messages in a thread wouldn't get marked as read when opening the thread (ie. existing behaviour)
Fixed a bug where going to the all media screen from a specific
Fixed a bug where the 'areCallsEnabled' preference wasn't getting migrated
Fixed a bug where you couldn't join any of the default open groups
Fixed a bug where it was polling for the invalid placeholder default open group
Fixed a few threading issues related to PromiseKit defaulting to run on the main thread
Updated and number of processes to run on "default" priority queues intead of "userInitiated" ones (since the docs suggest those are blocking)
Optimised the PagedDatabaseObserver to do a much more efficient count query
Updated the PagedDatabaseObserver to allow for triggering content updates when data changes outside of the paged or associated tables changes
Updated the HomeVC and MessageRequestsViewController to use paged queries
Made some optimisations to prevent unneeded database changes
pull/612/head
Morgan Pretty 3 months ago
parent
commit
20dc74bc96
  1. 13
      Session/Conversations/ConversationViewModel.swift
  2. 3
      Session/Conversations/Message Cells/Content Views/DeletedMessageView.swift
  3. 4
      Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift
  4. 8
      Session/Conversations/Message Cells/Content Views/OpenGroupInvitationView.swift
  5. 4
      Session/Conversations/Message Cells/Content Views/QuoteView.swift
  6. 3
      Session/Conversations/Message Cells/InfoMessageCell.swift
  7. 5
      Session/Conversations/Message Cells/VisibleMessageCell.swift
  8. 198
      Session/Home/HomeVC.swift
  9. 333
      Session/Home/HomeViewModel.swift
  10. 176
      Session/Home/Message Requests/MessageRequestsViewController.swift
  11. 172
      Session/Home/Message Requests/MessageRequestsViewModel.swift
  12. 59
      Session/Media Viewing & Editing/MediaGalleryViewModel.swift
  13. 3
      Session/Media Viewing & Editing/MediaPageViewController.swift
  14. 1
      Session/Notifications/PushRegistrationManager.swift
  15. 35
      Session/Notifications/SyncPushTokensJob.swift
  16. 7
      Session/Onboarding/DisplayNameVC.swift
  17. 2
      Session/Open Groups/JoinOpenGroupVC.swift
  18. 1
      Session/Settings/SettingsVC.swift
  19. 1
      Session/Utilities/BackgroundPoller.swift
  20. 3
      SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift
  21. 1
      SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift
  22. 5
      SessionMessagingKit/Database/Models/Attachment.swift
  23. 51
      SessionMessagingKit/Database/Models/OpenGroup.swift
  24. 15
      SessionMessagingKit/Database/Models/SessionThread.swift
  25. 7
      SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift
  26. 2
      SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift
  27. 1
      SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift
  28. 1
      SessionMessagingKit/Jobs/Types/FailedAttachmentDownloadsJob.swift
  29. 1
      SessionMessagingKit/Jobs/Types/FailedMessageSendsJob.swift
  30. 1
      SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift
  31. 1
      SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift
  32. 5
      SessionMessagingKit/Jobs/Types/MessageSendJob.swift
  33. 11
      SessionMessagingKit/Jobs/Types/NotifyPushServerJob.swift
  34. 9
      SessionMessagingKit/Jobs/Types/RetrieveDefaultOpenGroupRoomsJob.swift
  35. 5
      SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift
  36. 2
      SessionMessagingKit/Jobs/Types/UpdateProfilePictureJob.swift
  37. 2
      SessionMessagingKit/Open Groups/OpenGroupAPI.swift
  38. 91
      SessionMessagingKit/Open Groups/OpenGroupManager.swift
  39. 60
      SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ConfigurationMessages.swift
  40. 13
      SessionMessagingKit/Sending & Receiving/MessageReceiver.swift
  41. 1
      SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift
  42. 17
      SessionMessagingKit/Sending & Receiving/MessageSender.swift
  43. 1
      SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift
  44. 32
      SessionMessagingKit/Shared Models/MessageViewModel.swift
  45. 460
      SessionMessagingKit/Shared Models/SessionThreadViewModel.swift
  46. 49
      SessionMessagingKit/Utilities/ProfileManager.swift
  47. 6
      SessionSnodeKit/GetSnodePoolJob.swift
  48. 43
      SessionUtilitiesKit/Database/Models/Setting.swift
  49. 227
      SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift
  50. 4
      SessionUtilitiesKit/JobRunner/JobRunner.swift
  51. 3
      SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift
  52. 17
      SignalUtilitiesKit/Profile Pictures/Identicon+ObjC.swift

13
Session/Conversations/ConversationViewModel.swift

@ -82,7 +82,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
// distinct stutter)
self.pagedDataObserver = self.setupPagedObserver(for: threadId)
// Run the initial query on a backgorund thread so we don't block the push transition
// Run the initial query on a background thread so we don't block the push transition
DispatchQueue.global(qos: .default).async { [weak self] in
// If we don't have a `initialFocusedId` then default to `.pageBefore` (it'll query
// from a `0` offset)
@ -159,10 +159,11 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
)
],
filterSQL: MessageViewModel.filterSQL(threadId: threadId),
groupSQL: MessageViewModel.groupSQL,
orderSQL: MessageViewModel.orderSQL,
dataQuery: MessageViewModel.baseQuery(
orderSQL: MessageViewModel.orderSQL,
baseFilterSQL: MessageViewModel.filterSQL(threadId: threadId)
groupSQL: MessageViewModel.groupSQL
),
associatedRecords: [
AssociatedRecord<MessageViewModel.AttachmentInteractionInfo, MessageViewModel>(
@ -388,13 +389,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
}
public func markAllAsRead() {
guard
let lastInteractionId: Int64 = self.interactionData
.first(where: { $0.model == .messages })?
.elements
.last?
.id
else { return }
guard let lastInteractionId: Int64 = self.threadData.interactionId else { return }
let threadId: String = self.threadData.threadId
let trySendReadReceipt: Bool = (self.threadData.threadIsMessageRequest == false)

3
Session/Conversations/Message Cells/Content Views/DeletedMessageView.swift

@ -27,13 +27,14 @@ final class DeletedMessageView: UIView {
private func setUpViewHierarchy(textColor: UIColor) {
// Image view
let icon = UIImage(named: "ic_trash")?
.withTint(textColor)?
.withRenderingMode(.alwaysTemplate)
.resizedImage(to: CGSize(
width: DeletedMessageView.iconSize,
height: DeletedMessageView.iconSize
))
let imageView = UIImageView(image: icon)
imageView.tintColor = textColor
imageView.contentMode = .center
imageView.set(.width, to: DeletedMessageView.iconImageViewSize)
imageView.set(.height, to: DeletedMessageView.iconImageViewSize)

4
Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift

@ -56,9 +56,9 @@ final class LinkPreviewView: UIView {
private lazy var cancelButton: UIButton = {
// FIXME: This will have issues with theme transitions
let tint: UIColor = (isLightMode ? .black : .white)
let result: UIButton = UIButton(type: .custom)
result.setImage(UIImage(named: "X")?.withTint(tint), for: UIControl.State.normal)
result.setImage(UIImage(named: "X")?.withRenderingMode(.alwaysTemplate), for: UIControl.State.normal)
result.tintColor = (isLightMode ? .black : .white)
let cancelButtonSize = LinkPreviewView.cancelButtonSize
result.set(.width, to: cancelButtonSize)

8
Session/Conversations/Message Cells/Content Views/OpenGroupInvitationView.swift

@ -65,9 +65,13 @@ final class OpenGroupInvitationView: UIView {
// Icon
let iconSize = OpenGroupInvitationView.iconSize
let iconName = (isOutgoing ? "Globe" : "Plus")
let icon = UIImage(named: iconName)?.withTint(.white)?.resizedImage(to: CGSize(width: iconSize, height: iconSize))
let iconImageViewSize = OpenGroupInvitationView.iconImageViewSize
let iconImageView = UIImageView(image: icon)
let iconImageView = UIImageView(
image: UIImage(named: iconName)?
.withRenderingMode(.alwaysTemplate)
.resizedImage(to: CGSize(width: iconSize, height: iconSize))
)
iconImageView.tintColor = .white
iconImageView.contentMode = .center
iconImageView.layer.cornerRadius = iconImageViewSize / 2
iconImageView.layer.masksToBounds = true

4
Session/Conversations/Message Cells/Content Views/QuoteView.swift

@ -233,8 +233,8 @@ final class QuoteView: UIView {
// Cancel button
let cancelButton = UIButton(type: .custom)
let tint: UIColor = isLightMode ? .black : .white
cancelButton.setImage(UIImage(named: "X")?.withTint(tint), for: UIControl.State.normal)
cancelButton.setImage(UIImage(named: "X")?.withRenderingMode(.alwaysTemplate), for: UIControl.State.normal)
cancelButton.tintColor = (isLightMode ? .black : .white)
cancelButton.set(.width, to: cancelButtonSize)
cancelButton.set(.height, to: cancelButtonSize)
cancelButton.addTarget(self, action: #selector(cancel), for: UIControl.Event.touchUpInside)

3
Session/Conversations/Message Cells/InfoMessageCell.swift

@ -72,7 +72,8 @@ final class InfoMessageCell: MessageCell {
}()
if let icon = icon {
iconImageView.image = icon.withTint(Colors.text)
iconImageView.image = icon.withRenderingMode(.alwaysTemplate)
iconImageView.tintColor = Colors.text
}
iconImageViewWidthConstraint.constant = (icon != nil) ? InfoMessageCell.iconSize : 0

5
Session/Conversations/Message Cells/VisibleMessageCell.swift

@ -98,7 +98,8 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
let size = VisibleMessageCell.replyButtonSize
result.set(.width, to: size)
result.set(.height, to: size)
result.image = UIImage(named: "ic_reply")!.withTint(Colors.text)
result.image = UIImage(named: "ic_reply")?.withRenderingMode(.alwaysTemplate)
result.tintColor = Colors.text
return result
}()
@ -726,7 +727,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
guard let cellViewModel: MessageViewModel = self.viewModel else { return }
let viewsToMove: [UIView] = [
bubbleView, profilePictureView, replyButton, timerView, messageStatusImageView
bubbleView, bubbleBackgroundView, profilePictureView, replyButton, timerView, messageStatusImageView
]
let translationX = gestureRecognizer.translation(in: self).x.clamp(-CGFloat.greatestFiniteMagnitude, 0)

198
Session/Home/HomeVC.swift

@ -8,15 +8,26 @@ import SessionUtilitiesKit
import SignalUtilitiesKit
final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConversationButtonSetDelegate, SeedReminderViewDelegate {
typealias Section = HomeViewModel.Section
typealias Item = SessionThreadViewModel
private static let loadingHeaderHeight: CGFloat = 20
private let viewModel: HomeViewModel = HomeViewModel()
private var dataChangeObservable: DatabaseCancellable?
private var hasLoadedInitialData: Bool = false
private var hasLoadedInitialStateData: Bool = false
private var hasLoadedInitialThreadData: Bool = false
private var isLoadingMore: Bool = false
// MARK: - Intialization
init() {
GRDBStorage.shared.addObserver(viewModel.pagedDataObserver)
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
preconditionFailure("Use init() instead.")
}
deinit {
NotificationCenter.default.removeObserver(self)
}
@ -71,6 +82,10 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve
result.dataSource = self
result.delegate = self
if #available(iOS 15.0, *) {
result.sectionHeaderTopPadding = 0
}
return result
}()
@ -151,7 +166,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve
tableViewTopConstraint = tableView.pin(.top, to: .bottom, of: seedReminderView)
}
else {
tableViewTopConstraint = tableView.pin(.top, to: .top, of: view, withInset: Values.smallSpacing)
tableViewTopConstraint = tableView.pin(.top, to: .top, of: view)
}
tableView.pin(.trailing, to: .trailing, of: view)
tableView.pin(.bottom, to: .bottom, of: view)
@ -211,8 +226,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
// Stop observing database changes
dataChangeObservable?.cancel()
stopObservingChanges()
}
@objc func applicationDidBecomeActive(_ notification: Notification) {
@ -220,8 +234,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve
}
@objc func applicationDidResignActive(_ notification: Notification) {
// Stop observing database changes
dataChangeObservable?.cancel()
stopObservingChanges()
}
// MARK: - Updating
@ -233,7 +246,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve
// If we haven't done the initial load the trigger it immediately (blocking the main
// thread so we remain on the launch screen until it completes to be consistent with
// the old behaviour)
scheduling: (hasLoadedInitialData ?
scheduling: (hasLoadedInitialStateData ?
.async(onQueue: .main) :
.immediate
),
@ -243,26 +256,27 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve
self?.handleUpdates(state)
}
)
self.viewModel.onThreadChange = { [weak self] updatedThreadData in
self?.handleThreadUpdates(updatedThreadData)
}
}
private func stopObservingChanges() {
// Stop observing database changes
dataChangeObservable?.cancel()
self.viewModel.onThreadChange = nil
}
private func handleUpdates(_ updatedState: HomeViewModel.State) {
private func handleUpdates(_ updatedState: HomeViewModel.State, initialLoad: Bool = false) {
// Ensure the first load runs without animations (if we don't do this the cells will animate
// in from a frame of CGRect.zero)
guard hasLoadedInitialData else {
hasLoadedInitialData = true
UIView.performWithoutAnimation { handleUpdates(updatedState) }
guard hasLoadedInitialStateData else {
hasLoadedInitialStateData = true
UIView.performWithoutAnimation { handleUpdates(updatedState, initialLoad: true) }
return
}
// Hide the 'loading conversations' label (now that we have received conversation data)
loadingConversationsLabel.isHidden = true
// Show the empty state if there is no data
emptyStateView.isHidden = (
!updatedState.sections.isEmpty &&
updatedState.sections.contains(where: { !$0.elements.isEmpty })
)
if updatedState.userProfile != self.viewModel.state.userProfile {
updateNavBarButtons()
}
@ -280,9 +294,36 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve
}
}
self.viewModel.updateState(updatedState)
}
private func handleThreadUpdates(_ updatedData: [HomeViewModel.SectionModel], initialLoad: Bool = false) {
// Ensure the first load runs without animations (if we don't do this the cells will animate
// in from a frame of CGRect.zero)
guard hasLoadedInitialThreadData else {
hasLoadedInitialThreadData = true
UIView.performWithoutAnimation { handleThreadUpdates(updatedData, initialLoad: true) }
return
}
// Hide the 'loading conversations' label (now that we have received conversation data)
loadingConversationsLabel.isHidden = true
// Show the empty state if there is no data
emptyStateView.isHidden = (
!updatedData.isEmpty &&
updatedData.contains(where: { !$0.elements.isEmpty })
)
CATransaction.begin()
CATransaction.setCompletionBlock { [weak self] in
// Complete page loading
self?.isLoadingMore = false
}
// Reload the table content (animate changes after the first load)
tableView.reload(
using: StagedChangeset(source: viewModel.state.sections, target: updatedState.sections),
using: StagedChangeset(source: viewModel.threadData, target: updatedData),
deleteSectionsAnimation: .none,
insertSectionsAnimation: .none,
reloadSectionsAnimation: .none,
@ -290,15 +331,11 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve
insertRowsAnimation: .top,
reloadRowsAnimation: .none,
interrupt: { $0.changeCount > 100 } // Prevent too many changes from causing performance issues
) { [weak self] updatedSections in
guard let currentState: HomeViewModel.State = self?.viewModel.state else { return }
self?.viewModel.updateState(currentState.with(sections: updatedSections))
) { [weak self] updatedData in
self?.viewModel.updateThreadData(updatedData)
}
self.viewModel.updateState(
self.viewModel.state.with(showViewedSeedBanner: updatedState.showViewedSeedBanner)
)
CATransaction.commit()
}
private func updateNavBarButtons() {
@ -357,35 +394,87 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve
// MARK: - UITableViewDataSource
func numberOfSections(in tableView: UITableView) -> Int {
return viewModel.state.sections.count
return viewModel.threadData.count
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return viewModel.state.sections[section].elements.count
let section: HomeViewModel.SectionModel = viewModel.threadData[section]
return section.elements.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let section: ArraySection<Section, Item> = viewModel.state.sections[indexPath.section]
let section: HomeViewModel.SectionModel = viewModel.threadData[indexPath.section]
switch section.model {
case .messageRequests:
let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row]
let cell: MessageRequestsCell = tableView.dequeue(type: MessageRequestsCell.self, for: indexPath)
cell.update(with: Int(section.elements[indexPath.row].threadUnreadCount ?? 0))
cell.update(with: Int(threadViewModel.threadUnreadCount ?? 0))
return cell
case .threads:
let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row]
let cell: FullConversationCell = tableView.dequeue(type: FullConversationCell.self, for: indexPath)
cell.update(with: section.elements[indexPath.row])
cell.update(with: threadViewModel)
return cell
default: preconditionFailure("Other sections should have no content")
}
}
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
let section: HomeViewModel.SectionModel = viewModel.threadData[section]
switch section.model {
case .loadMore:
let loadingIndicator: UIActivityIndicatorView = UIActivityIndicatorView(style: .medium)
loadingIndicator.tintColor = Colors.text
loadingIndicator.alpha = 0.5
loadingIndicator.startAnimating()
let view: UIView = UIView()
view.addSubview(loadingIndicator)
loadingIndicator.center(in: view)
return view
default: return nil
}
}
// MARK: - UITableViewDelegate
func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
let section: HomeViewModel.SectionModel = viewModel.threadData[section]
switch section.model {
case .loadMore: return HomeVC.loadingHeaderHeight
default: return 0
}
}
func tableView(_ tableView: UITableView, willDisplayHeaderView view: UIView, forSection section: Int) {
guard self.hasLoadedInitialThreadData && !self.isLoadingMore else { return }
let section: HomeViewModel.SectionModel = self.viewModel.threadData[section]
switch section.model {
case .loadMore:
self.isLoadingMore = true
DispatchQueue.global(qos: .default).async { [weak self] in
self?.viewModel.pagedDataObserver?.load(.pageAfter)
}
default: break
}
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
let section: ArraySection<Section, Item> = viewModel.state.sections[indexPath.section]
let section: HomeViewModel.SectionModel = self.viewModel.threadData[indexPath.section]
switch section.model {
case .messageRequests:
@ -393,13 +482,16 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve
self.navigationController?.pushViewController(viewController, animated: true)
case .threads:
let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row]
show(
section.elements[indexPath.row].threadId,
variant: section.elements[indexPath.row].threadVariant,
threadViewModel.threadId,
variant: threadViewModel.threadVariant,
with: .none,
focusedInteractionId: nil,
animated: true
)
default: break
}
}
@ -408,7 +500,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve
}
func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? {
let section: ArraySection<Section, Item> = viewModel.state.sections[indexPath.section]
let section: HomeViewModel.SectionModel = self.viewModel.threadData[indexPath.section]
switch section.model {
case .messageRequests:
@ -420,12 +512,12 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve
return [hide]
case .threads:
let cellViewModel: SessionThreadViewModel = section.elements[indexPath.row]
let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row]
let delete: UITableViewRowAction = UITableViewRowAction(
style: .destructive,
title: "TXT_DELETE_TITLE".localized()
) { [weak self] _, _ in
let message = (cellViewModel.currentUserIsClosedGroupAdmin == true ?
let message = (threadViewModel.currentUserIsClosedGroupAdmin == true ?
"admin_group_leave_warning".localized() :
"CONVERSATION_DELETE_CONFIRMATION_ALERT_MESSAGE".localized()
)
@ -440,20 +532,20 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve
style: .destructive
) { _ in
GRDBStorage.shared.writeAsync { db in
switch cellViewModel.threadVariant {
switch threadViewModel.threadVariant {
case .closedGroup:
try MessageSender
.leave(db, groupPublicKey: cellViewModel.threadId)
.leave(db, groupPublicKey: threadViewModel.threadId)
.retainUntilComplete()
case .openGroup:
OpenGroupManager.shared.delete(db, openGroupId: cellViewModel.threadId)
OpenGroupManager.shared.delete(db, openGroupId: threadViewModel.threadId)
default: break
}
_ = try SessionThread
.filter(id: cellViewModel.threadId)
.filter(id: threadViewModel.threadId)
.deleteAll(db)
}
})
@ -468,36 +560,36 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve
let pin: UITableViewRowAction = UITableViewRowAction(
style: .normal,
title: (cellViewModel.threadIsPinned ?
title: (threadViewModel.threadIsPinned ?
"UNPIN_BUTTON_TEXT".localized() :
"PIN_BUTTON_TEXT".localized()
)
) { _, _ in
GRDBStorage.shared.writeAsync { db in
try SessionThread
.filter(id: cellViewModel.threadId)
.updateAll(db, SessionThread.Columns.isPinned.set(to: !cellViewModel.threadIsPinned))
.filter(id: threadViewModel.threadId)
.updateAll(db, SessionThread.Columns.isPinned.set(to: !threadViewModel.threadIsPinned))
}
}
guard cellViewModel.threadVariant == .contact && !cellViewModel.threadIsNoteToSelf else {
guard threadViewModel.threadVariant == .contact && !threadViewModel.threadIsNoteToSelf else {
return [ delete, pin ]
}
let block: UITableViewRowAction = UITableViewRowAction(
style: .normal,
title: (cellViewModel.threadIsBlocked == true ?
title: (threadViewModel.threadIsBlocked == true ?
"BLOCK_LIST_UNBLOCK_BUTTON".localized() :
"BLOCK_LIST_BLOCK_BUTTON".localized()
)
) { _, _ in
GRDBStorage.shared.writeAsync { db in
try Contact
.filter(id: cellViewModel.threadId)
.filter(id: threadViewModel.threadId)
.updateAll(
db,
Contact.Columns.isBlocked.set(
to: (cellViewModel.threadIsBlocked == false ?
to: (threadViewModel.threadIsBlocked == false ?
true:
false
)
@ -510,6 +602,8 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve
block.backgroundColor = Colors.unimportant
return [ delete, block, pin ]
default: return []
}
}

333
Session/Home/HomeViewModel.swift

@ -4,37 +4,179 @@ import Foundation
import GRDB
import DifferenceKit
import SignalUtilitiesKit
import SessionUtilitiesKit
public class HomeViewModel {
public typealias SectionModel = ArraySection<Section, SessionThreadViewModel>
// MARK: - Section
public enum Section: Differentiable {
case messageRequests
case threads
case loadMore
}
// MARK: - Variables
public static let pageSize: Int = 14
public struct State: Equatable {
let showViewedSeedBanner: Bool
let hasHiddenMessageRequests: Bool
let unreadMessageRequestThreadCount: Int
let userProfile: Profile?
let sections: [ArraySection<Section, SessionThreadViewModel>]
func with(
showViewedSeedBanner: Bool? = nil,
userProfile: Profile? = nil,
sections: [ArraySection<Section, SessionThreadViewModel>]? = nil
) -> State {
return State(
showViewedSeedBanner: (showViewedSeedBanner ?? self.showViewedSeedBanner),
userProfile: (userProfile ?? self.userProfile),
sections: (sections ?? self.sections)
)
init(
showViewedSeedBanner: Bool = !GRDBStorage.shared[.hasViewedSeed],
hasHiddenMessageRequests: Bool = GRDBStorage.shared[.hasHiddenMessageRequests],
unreadMessageRequestThreadCount: Int = 0,
userProfile: Profile? = nil
) {
self.showViewedSeedBanner = showViewedSeedBanner
self.hasHiddenMessageRequests = hasHiddenMessageRequests
self.unreadMessageRequestThreadCount = unreadMessageRequestThreadCount
self.userProfile = userProfile
}
}
// MARK: - Initialization
init() {
self.state = GRDBStorage.shared.read { db in try HomeViewModel.retrieveState(db) }
.defaulting(to: State())
self.pagedDataObserver = nil
// Note: Since this references self we need to finish initializing before setting it, we
// also want to skip the initial query and trigger it async so that the push animation
// doesn't stutter (it should load basically immediately but without this there is a
// distinct stutter)
let userPublicKey: String = getUserHexEncodedPublicKey()
let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
self.pagedDataObserver = PagedDatabaseObserver(
pagedTable: SessionThread.self,
pageSize: HomeViewModel.pageSize,
idColumn: .id,
observedChanges: [
PagedData.ObservedChanges(
table: SessionThread.self,
columns: [
.id,
.shouldBeVisible,
.isPinned,
.mutedUntilTimestamp,
.onlyNotifyForMentions
]
),
PagedData.ObservedChanges(
table: Interaction.self,
columns: [
.body,
.wasRead
],
joinToPagedType: {
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
return SQL("LEFT JOIN \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id])")
}()
),
PagedData.ObservedChanges(
table: Contact.self,
columns: [.isBlocked],
joinToPagedType: {
let contact: TypedTableAlias<Contact> = TypedTableAlias()
return SQL("LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id])")
}()
),
PagedData.ObservedChanges(
table: Profile.self,
columns: [.name, .nickname, .profilePictureFileName],
joinToPagedType: {
let profile: TypedTableAlias<Profile> = TypedTableAlias()
return SQL("LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(thread[.id])")
}()
),
PagedData.ObservedChanges(
table: ClosedGroup.self,
columns: [.name],
joinToPagedType: {
let closedGroup: TypedTableAlias<ClosedGroup> = TypedTableAlias()
return SQL("LEFT JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id])")
}()
),
PagedData.ObservedChanges(
table: OpenGroup.self,
columns: [.name, .imageData],
joinToPagedType: {
let openGroup: TypedTableAlias<OpenGroup> = TypedTableAlias()
return SQL("LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id])")
}()
),
PagedData.ObservedChanges(
table: RecipientState.self,
columns: [.state],
joinToPagedType: {
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
let recipientState: TypedTableAlias<RecipientState> = TypedTableAlias()
return """
LEFT JOIN \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id])
LEFT JOIN \(RecipientState.self) ON \(recipientState[.interactionId]) = \(interaction[.id])
"""
}()
),
PagedData.ObservedChanges(
table: ThreadTypingIndicator.self,
columns: [.threadId],
joinToPagedType: {
let typingIndicator: TypedTableAlias<ThreadTypingIndicator> = TypedTableAlias()
return SQL("LEFT JOIN \(typingIndicator[.threadId]) = \(thread[.id])")
}()
)
],
/// **Note:** This `optimisedJoinSQL` value includes the required minimum joins needed for the query
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
),
onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in
guard let updatedThreadData: [SectionModel] = self?.process(data: updatedData, for: updatedPageInfo) else {
return
}
// If we have the 'onThreadChange' callback then trigger it, otherwise just store the changes
// to be sent to the callback if we ever start observing again (when we have the callback it needs
// to do the data updating as it's tied to UI updates and can cause crashes if not updated in the
// correct order)
guard let onThreadChange: (([SectionModel]) -> ()) = self?.onThreadChange else {
self?.unobservedThreadDataChanges = updatedThreadData
return
}
onThreadChange(updatedThreadData)
}
)
// Run the initial query on the main thread so we prevent the app from leaving the loading screen
// until we have data (Note: the `.pageBefore` will query from a `0` offset loading the first page)
self.pagedDataObserver?.load(.pageBefore)
}
// MARK: - State
/// This value is the current state of the view
public private(set) var state: State = State(
showViewedSeedBanner: !GRDBStorage.shared[.hasViewedSeed],
userProfile: nil,
sections: []
)
public private(set) var state: State
/// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise
/// performance https://github.com/groue/GRDB.swift#valueobservation-performance
@ -44,75 +186,104 @@ public class HomeViewModel {
/// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`)
/// just in case the database has changed between the two reads - unfortunately it doesn't look like there is a way to prevent this
public lazy var observableState = ValueObservation
.tracking(
regions: [
// We explicitly define the regions we want to track as the automatic detection
// seems to include a bunch of columns we will fetch but probably don't need to
// track changes for
SessionThread.select(
.id,
.shouldBeVisible,
.isPinned,
.mutedUntilTimestamp,
.onlyNotifyForMentions
),
Setting.filter(ids: [
Setting.BoolKey.hasHiddenMessageRequests.rawValue,
Setting.BoolKey.hasViewedSeed.rawValue
]),
Contact.select(.isBlocked, .isApproved), // 'isApproved' for message requests
Profile.select(.name, .nickname, .profilePictureFileName),
ClosedGroup.select(.name),
OpenGroup.select(.name, .imageData),
GroupMember.select(.groupId),
Interaction.select(
.body,
.wasRead
),
Attachment.select(.state),
RecipientState.select(.state),
ThreadTypingIndicator.select(.threadId)
],
fetch: { db -> State in
let hasViewedSeed: Bool = db[.hasViewedSeed]
let userProfile: Profile = Profile.fetchOrCreateCurrentUser(db)
let unreadMessageRequestCount: Int = try SessionThread
.unreadMessageRequestsQuery(userPublicKey: userProfile.id)
.fetchCount(db)
let finalUnreadMessageRequestCount: Int = (db[.hasHiddenMessageRequests] ? 0 : unreadMessageRequestCount)
let threads: [SessionThreadViewModel] = try SessionThreadViewModel
.homeQuery(userPublicKey: userProfile.id)
.fetchAll(db)
return State(
showViewedSeedBanner: !hasViewedSeed,
userProfile: userProfile,
sections: [
ArraySection(
model: .messageRequests,
elements: [
// If there are no unread message requests then hide the message request banner
(finalUnreadMessageRequestCount == 0 ?
nil :
SessionThreadViewModel(
unreadCount: UInt(finalUnreadMessageRequestCount)
)
)
].compactMap { $0 }
),
ArraySection(
model: .threads,
elements: threads
)
]
)
}
)
.trackingConstantRegion { db -> State in try HomeViewModel.retrieveState(db) }
.removeDuplicates()
// MARK: - Functions
private static func retrieveState(_ db: Database) throws -> State {
let hasViewedSeed: Bool = db[.hasViewedSeed]
let hasHiddenMessageRequests: Bool = db[.hasHiddenMessageRequests]
let userProfile: Profile = Profile.fetchOrCreateCurrentUser(db)
let unreadMessageRequestThreadCount: Int = try SessionThread
.unreadMessageRequestsThreadIdQuery(userPublicKey: userProfile.id)
.fetchCount(db)
return State(
showViewedSeedBanner: !hasViewedSeed,
hasHiddenMessageRequests: hasHiddenMessageRequests,
unreadMessageRequestThreadCount: unreadMessageRequestThreadCount,
userProfile: userProfile
)
}
public func updateState(_ updatedState: State) {
let oldState: State = self.state
self.state = updatedState
// If the messageRequest content changed then we need to re-process the thread data
guard
(
oldState.hasHiddenMessageRequests != updatedState.hasHiddenMessageRequests ||
oldState.unreadMessageRequestThreadCount != updatedState.unreadMessageRequestThreadCount
),
let currentPageInfo: PagedData.PageInfo = self.pagedDataObserver?.pageInfo.wrappedValue
else { return }
/// **MUST** have the same logic as in the 'PagedDataObserver.onChangeUnsorted' above
let currentData: [SessionThreadViewModel] = self.threadData.flatMap { $0.elements }
let updatedThreadData: [SectionModel] = self.process(data: currentData, for: currentPageInfo)
guard let onThreadChange: (([SectionModel]) -> ()) = self.onThreadChange else {
self.unobservedThreadDataChanges = updatedThreadData
return
}
onThreadChange(updatedThreadData)
}
// MARK: - Thread Data
public private(set) var unobservedThreadDataChanges: [SectionModel]?
public private(set) var threadData: [SectionModel] = []
public private(set) var pagedDataObserver: PagedDatabaseObserver<SessionThread, SessionThreadViewModel>?
public var onThreadChange: (([SectionModel]) -> ())? {
didSet {
// When starting to observe interaction changes we want to trigger a UI update just in case the
// data was changed while we weren't observing
if let unobservedThreadDataChanges: [SectionModel] = self.unobservedThreadDataChanges {
onThreadChange?(unobservedThreadDataChanges)
self.unobservedThreadDataChanges = nil
}
}
}
private func process(data: [SessionThreadViewModel], for pageInfo: PagedData.PageInfo) -> [SectionModel] {
let finalUnreadMessageRequestCount: Int = (self.state.hasHiddenMessageRequests ?
0 :
self.state.unreadMessageRequestThreadCount
)
return [
// If there are no unread message requests then hide the message request banner
(finalUnreadMessageRequestCount == 0 ?
[] :
[SectionModel(
section: .messageRequests,
elements: [
SessionThreadViewModel(unreadCount: UInt(finalUnreadMessageRequestCount))
]
)]
),
[
SectionModel(
section: .threads,
elements: data
.sorted { lhs, rhs -> Bool in
if lhs.threadIsPinned && !rhs.threadIsPinned { return true }
if !lhs.threadIsPinned && rhs.threadIsPinned { return false }
return lhs.lastInteractionDate > rhs.lastInteractionDate
}
)
],
(!data.isEmpty && (pageInfo.pageOffset + pageInfo.currentCount) < pageInfo.totalCount ?
[SectionModel(section: .loadMore)] :
[]
)
].flatMap { $0 }
}
public func updateThreadData(_ updatedData: [SectionModel]) {
self.threadData = updatedData
}
}

176
Session/Home/Message Requests/MessageRequestsViewController.swift

@ -8,9 +8,28 @@ import SessionMessagingKit
import SignalUtilitiesKit
class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDataSource {
private static let loadingHeaderHeight: CGFloat = 20
private let viewModel: MessageRequestsViewModel = MessageRequestsViewModel()
private var dataChangeObservable: DatabaseCancellable?
private var hasLoadedInitialData: Bool = false
private var hasLoadedInitialThreadData: Bool = false
private var isLoadingMore: Bool = false
// MARK: - Intialization
init() {
GRDBStorage.shared.addObserver(viewModel.pagedDataObserver)
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
preconditionFailure("Use init() instead.")
}
deinit {
NotificationCenter.default.removeObserver(self)
}
// MARK: - UI
@ -26,6 +45,10 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
let bottomInset = Values.newConversationButtonBottomOffset + NewConversationButtonSet.expandedButtonSize + Values.largeSpacing + NewConversationButtonSet.collapsedButtonSize
result.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: bottomInset, right: 0)
result.showsVerticalScrollIndicator = false
if #available(iOS 15.0, *) {
result.sectionHeaderTopPadding = 0
}
return result
}()
@ -122,10 +145,6 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
// Stop observing database changes
dataChangeObservable?.cancel()
}
deinit {
NotificationCenter.default.removeObserver(self)
}
// MARK: - Layout
@ -159,33 +178,27 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
// MARK: - Updating
private func startObservingChanges() {
// Start observing for data changes
dataChangeObservable = GRDBStorage.shared.start(
viewModel.observableViewData,
onError: { _ in },
onChange: { [weak self] viewData in
// The defaul scheduler emits changes on the main thread
self?.handleUpdates(viewData)
}
)
self.viewModel.onThreadChange = { [weak self] updatedThreadData in
self?.handleThreadUpdates(updatedThreadData)
}
}
private func handleUpdates(_ updatedViewData: [SessionThreadViewModel]) {
private func handleThreadUpdates(_ updatedData: [MessageRequestsViewModel.SectionModel], initialLoad: Bool = false) {
// Ensure the first load runs without animations (if we don't do this the cells will animate
// in from a frame of CGRect.zero)
guard hasLoadedInitialData else {
hasLoadedInitialData = true
UIView.performWithoutAnimation { handleUpdates(updatedViewData) }
guard hasLoadedInitialThreadData else {
hasLoadedInitialThreadData = true
UIView.performWithoutAnimation { handleThreadUpdates(updatedData, initialLoad: true) }
return
}
// Show the empty state if there is no data
clearAllButton.isHidden = updatedViewData.isEmpty
emptyStateLabel.isHidden = !updatedViewData.isEmpty
clearAllButton.isHidden = updatedData.isEmpty
emptyStateLabel.isHidden = !updatedData.isEmpty
// Reload the table content (animate changes after the first load)
tableView.reload(
using: StagedChangeset(source: viewModel.viewData, target: updatedViewData),
using: StagedChangeset(source: viewModel.threadData, target: updatedData),
deleteSectionsAnimation: .none,
insertSectionsAnimation: .none,
reloadSectionsAnimation: .none,
@ -194,7 +207,7 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
reloadRowsAnimation: .none,
interrupt: { $0.changeCount > 100 } // Prevent too many changes from causing performance issues
) { [weak self] updatedData in
self?.viewModel.updateData(updatedData)
self?.viewModel.updateThreadData(updatedData)
}
}
@ -208,26 +221,94 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
// MARK: - UITableViewDataSource
func numberOfSections(in tableView: UITableView) -> Int {
return viewModel.threadData.count
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return viewModel.viewData.count
let section: MessageRequestsViewModel.SectionModel = viewModel.threadData[section]
return section.elements.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell: FullConversationCell = tableView.dequeue(type: FullConversationCell.self, for: indexPath)
cell.update(with: viewModel.viewData[indexPath.row])
return cell
let section: MessageRequestsViewModel.SectionModel = viewModel.threadData[indexPath.section]
switch section.model {
case .threads:
let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row]
let cell: FullConversationCell = tableView.dequeue(type: FullConversationCell.self, for: indexPath)
cell.update(with: threadViewModel)
return cell
default: preconditionFailure("Other sections should have no content")
}
}
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
let section: MessageRequestsViewModel.SectionModel = viewModel.threadData[section]
switch section.model {
case .loadMore:
let loadingIndicator: UIActivityIndicatorView = UIActivityIndicatorView(style: .medium)
loadingIndicator.tintColor = Colors.text
loadingIndicator.alpha = 0.5
loadingIndicator.startAnimating()
let view: UIView = UIView()
view.addSubview(loadingIndicator)
loadingIndicator.center(in: view)
return view
default: return nil
}
}
// MARK: - UITableViewDelegate
func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
let section: MessageRequestsViewModel.SectionModel = viewModel.threadData[section]
switch section.model {
case .loadMore: return MessageRequestsViewController.loadingHeaderHeight
default: return 0
}
}
func tableView(_ tableView: UITableView, willDisplayHeaderView view: UIView, forSection section: Int) {
guard self.hasLoadedInitialThreadData && !self.isLoadingMore else { return }
let section: MessageRequestsViewModel.SectionModel = self.viewModel.threadData[section]
switch section.model {
case .loadMore:
self.isLoadingMore = true
DispatchQueue.global(qos: .default).async { [weak self] in
self?.viewModel.pagedDataObserver?.load(.pageAfter)
}
default: break
}
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
let conversationVC: ConversationVC = ConversationVC(
threadId: viewModel.viewData[indexPath.row].threadId,
threadVariant: viewModel.viewData[indexPath.row].threadVariant
)
self.navigationController?.pushViewController(conversationVC, animated: true)
let section: MessageRequestsViewModel.SectionModel = self.viewModel.threadData[indexPath.section]
switch section.model {
case .threads:
let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row]
let conversationVC: ConversationVC = ConversationVC(
threadId: threadViewModel.threadId,
threadVariant: threadViewModel.threadVariant
)
self.navigationController?.pushViewController(conversationVC, animated: true)
default: break
}
}
func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
@ -235,24 +316,35 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
}
func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? {
let threadId: String = viewModel.viewData[indexPath.row].threadId
let delete = UITableViewRowAction(
style: .destructive,
title: "TXT_DELETE_TITLE".localized()
) { [weak self] _, _ in
self?.delete(threadId)
}
delete.backgroundColor = Colors.destructive
let section: MessageRequestsViewModel.SectionModel = self.viewModel.threadData[indexPath.section]
switch section.model {
case .threads:
let threadId: String = section.elements[indexPath.row].threadId