mirror of https://github.com/oxen-io/session-ios
Merge remote-tracking branch 'upstream/dev' into disappearing-message-redesign
# Conflicts: # Session.xcodeproj/project.pbxproj # Session/Conversations/Settings/ThreadDisappearingMessagesSettingsViewModel.swift # Session/Conversations/Settings/ThreadSettingsViewModel.swift # Session/Shared/SessionTableViewController.swift # Session/Shared/SessionTableViewModel.swift # Session/Shared/Types/SessionTableSection.swift # SessionTests/Conversations/Settings/ThreadDisappearingMessagesViewModelSpec.swift # SessionUIKit/Components/SessionButton.swiftpull/731/head
commit
a6931bb922
@ -1,486 +0,0 @@
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import GRDB
|
||||
import DifferenceKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
import SignalUtilitiesKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
class MessageRequestsViewController: BaseVC, SessionUtilRespondingViewController, UITableViewDelegate, UITableViewDataSource {
|
||||
private static let loadingHeaderHeight: CGFloat = 40
|
||||
|
||||
private let viewModel: MessageRequestsViewModel = MessageRequestsViewModel()
|
||||
private var hasLoadedInitialThreadData: Bool = false
|
||||
private var isLoadingMore: Bool = false
|
||||
private var isAutoLoadingNextPage: Bool = false
|
||||
private var viewHasAppeared: Bool = false
|
||||
|
||||
// MARK: - SessionUtilRespondingViewController
|
||||
|
||||
let isConversationList: Bool = true
|
||||
|
||||
// MARK: - Intialization
|
||||
|
||||
init() {
|
||||
Storage.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
|
||||
|
||||
private lazy var loadingConversationsLabel: UILabel = {
|
||||
let result: UILabel = UILabel()
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
result.text = "LOADING_CONVERSATIONS".localized()
|
||||
result.themeTextColor = .textSecondary
|
||||
result.textAlignment = .center
|
||||
result.numberOfLines = 0
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var tableView: UITableView = {
|
||||
let result: UITableView = UITableView()
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.separatorStyle = .none
|
||||
result.themeBackgroundColor = .clear
|
||||
result.showsVerticalScrollIndicator = false
|
||||
result.contentInset = UIEdgeInsets(
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: Values.footerGradientHeight(window: UIApplication.shared.keyWindow),
|
||||
right: 0
|
||||
)
|
||||
result.register(view: FullConversationCell.self)
|
||||
result.dataSource = self
|
||||
result.delegate = self
|
||||
|
||||
if #available(iOS 15.0, *) {
|
||||
result.sectionHeaderTopPadding = 0
|
||||
}
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var emptyStateLabel: UILabel = {
|
||||
let result: UILabel = UILabel()
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.isUserInteractionEnabled = false
|
||||
result.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
result.text = "MESSAGE_REQUESTS_EMPTY_TEXT".localized()
|
||||
result.themeTextColor = .textSecondary
|
||||
result.textAlignment = .center
|
||||
result.numberOfLines = 0
|
||||
result.isHidden = true
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var fadeView: GradientView = {
|
||||
let result: GradientView = GradientView()
|
||||
result.themeBackgroundGradient = [
|
||||
.value(.backgroundPrimary, alpha: 0), // Want this to take up 20% (~25pt)
|
||||
.backgroundPrimary,
|
||||
.backgroundPrimary,
|
||||
.backgroundPrimary,
|
||||
.backgroundPrimary
|
||||
]
|
||||
result.set(.height, to: Values.footerGradientHeight(window: UIApplication.shared.keyWindow))
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var clearAllButton: SessionButton = {
|
||||
let result: SessionButton = SessionButton(style: .destructive, size: .large)
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.setTitle("MESSAGE_REQUESTS_CLEAR_ALL".localized(), for: .normal)
|
||||
result.addTarget(self, action: #selector(clearAllTapped), for: .touchUpInside)
|
||||
result.accessibilityIdentifier = "Clear all"
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
ViewControllerUtilities.setUpDefaultSessionStyle(
|
||||
for: self,
|
||||
title: "MESSAGE_REQUESTS_TITLE".localized(),
|
||||
hasCustomBackButton: false
|
||||
)
|
||||
|
||||
// Add the UI (MUST be done after the thread freeze so the 'tableView' creation and setting
|
||||
// the dataSource has the correct data)
|
||||
view.addSubview(loadingConversationsLabel)
|
||||
view.addSubview(tableView)
|
||||
view.addSubview(emptyStateLabel)
|
||||
view.addSubview(fadeView)
|
||||
view.addSubview(clearAllButton)
|
||||
setupLayout()
|
||||
|
||||
// Notifications
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(applicationDidBecomeActive(_:)),
|
||||
name: UIApplication.didBecomeActiveNotification,
|
||||
object: nil
|
||||
)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(applicationDidResignActive(_:)),
|
||||
name: UIApplication.didEnterBackgroundNotification, object: nil
|
||||
)
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
startObservingChanges()
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
|
||||
self.viewHasAppeared = true
|
||||
self.autoLoadNextPageIfNeeded()
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
|
||||
stopObservingChanges()
|
||||
}
|
||||
|
||||
@objc func applicationDidBecomeActive(_ notification: Notification) {
|
||||
/// Need to dispatch to the next run loop to prevent a possible crash caused by the database resuming mid-query
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.startObservingChanges(didReturnFromBackground: true)
|
||||
}
|
||||
}
|
||||
|
||||
@objc func applicationDidResignActive(_ notification: Notification) {
|
||||
stopObservingChanges()
|
||||
}
|
||||
|
||||
// MARK: - Layout
|
||||
|
||||
private func setupLayout() {
|
||||
NSLayoutConstraint.activate([
|
||||
loadingConversationsLabel.topAnchor.constraint(equalTo: view.topAnchor, constant: Values.veryLargeSpacing),
|
||||
loadingConversationsLabel.leftAnchor.constraint(equalTo: view.leftAnchor, constant: Values.massiveSpacing),
|
||||
loadingConversationsLabel.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -Values.massiveSpacing),
|
||||
|
||||
tableView.topAnchor.constraint(equalTo: view.topAnchor, constant: Values.smallSpacing),
|
||||
tableView.leftAnchor.constraint(equalTo: view.leftAnchor),
|
||||
tableView.rightAnchor.constraint(equalTo: view.rightAnchor),
|
||||
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
|
||||
emptyStateLabel.topAnchor.constraint(equalTo: view.topAnchor, constant: Values.massiveSpacing),
|
||||
emptyStateLabel.leftAnchor.constraint(equalTo: view.leftAnchor, constant: Values.mediumSpacing),
|
||||
emptyStateLabel.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -Values.mediumSpacing),
|
||||
emptyStateLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||
|
||||
fadeView.leftAnchor.constraint(equalTo: view.leftAnchor),
|
||||
fadeView.rightAnchor.constraint(equalTo: view.rightAnchor),
|
||||
fadeView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
|
||||
clearAllButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||
clearAllButton.bottomAnchor.constraint(
|
||||
equalTo: view.safeAreaLayoutGuide.bottomAnchor,
|
||||
constant: -Values.smallSpacing
|
||||
),
|
||||
clearAllButton.widthAnchor.constraint(equalToConstant: Values.iPadButtonWidth)
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: - Updating
|
||||
|
||||
private func startObservingChanges(didReturnFromBackground: Bool = false) {
|
||||
self.viewModel.onThreadChange = { [weak self] updatedThreadData, changeset in
|
||||
self?.handleThreadUpdates(updatedThreadData, changeset: changeset)
|
||||
}
|
||||
|
||||
// Note: When returning from the background we could have received notifications but the
|
||||
// PagedDatabaseObserver won't have them so we need to force a re-fetch of the current
|
||||
// data to ensure everything is up to date
|
||||
if didReturnFromBackground {
|
||||
self.viewModel.pagedDataObserver?.reload()
|
||||
}
|
||||
}
|
||||
|
||||
private func stopObservingChanges() {
|
||||
self.viewModel.onThreadChange = nil
|
||||
}
|
||||
|
||||
private func handleThreadUpdates(
|
||||
_ updatedData: [MessageRequestsViewModel.SectionModel],
|
||||
changeset: StagedChangeset<[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 hasLoadedInitialThreadData else {
|
||||
UIView.performWithoutAnimation {
|
||||
// Hide the 'loading conversations' label (now that we have received conversation data)
|
||||
loadingConversationsLabel.isHidden = true
|
||||
|
||||
// Show the empty state if there is no data
|
||||
clearAllButton.isHidden = !(updatedData.first?.elements.isEmpty == false)
|
||||
emptyStateLabel.isHidden = !clearAllButton.isHidden
|
||||
|
||||
// Update the content
|
||||
viewModel.updateThreadData(updatedData)
|
||||
tableView.reloadData()
|
||||
hasLoadedInitialThreadData = 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
|
||||
clearAllButton.isHidden = !(updatedData.first?.elements.isEmpty == false)
|
||||
emptyStateLabel.isHidden = !clearAllButton.isHidden
|
||||
|
||||
CATransaction.begin()
|
||||
CATransaction.setCompletionBlock { [weak self] in
|
||||
// Complete page loading
|
||||
self?.isLoadingMore = false
|
||||
self?.autoLoadNextPageIfNeeded()
|
||||
}
|
||||
|
||||
// Reload the table content (animate changes after the first load)
|
||||
tableView.reload(
|
||||
using: changeset,
|
||||
deleteSectionsAnimation: .none,
|
||||
insertSectionsAnimation: .none,
|
||||
reloadSectionsAnimation: .none,
|
||||
deleteRowsAnimation: .bottom,
|
||||
insertRowsAnimation: .top,
|
||||
reloadRowsAnimation: .none,
|
||||
interrupt: { $0.changeCount > 100 } // Prevent too many changes from causing performance issues
|
||||
) { [weak self] updatedData in
|
||||
self?.viewModel.updateThreadData(updatedData)
|
||||
}
|
||||
|
||||
CATransaction.commit()
|
||||
}
|
||||
|
||||
private func autoLoadNextPageIfNeeded() {
|
||||
guard
|
||||
self.hasLoadedInitialThreadData &&
|
||||
!self.isAutoLoadingNextPage &&
|
||||
!self.isLoadingMore
|
||||
else { return }
|
||||
|
||||
self.isAutoLoadingNextPage = true
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + PagedData.autoLoadNextPageDelay) { [weak self] in
|
||||
self?.isAutoLoadingNextPage = false
|
||||
|
||||
// Note: We sort the headers as we want to prioritise loading newer pages over older ones
|
||||
let sections: [(MessageRequestsViewModel.Section, CGRect)] = (self?.viewModel.threadData
|
||||
.enumerated()
|
||||
.map { index, section in (section.model, (self?.tableView.rectForHeader(inSection: index) ?? .zero)) })
|
||||
.defaulting(to: [])
|
||||
let shouldLoadMore: Bool = sections
|
||||
.contains { section, headerRect in
|
||||
section == .loadMore &&
|
||||
headerRect != .zero &&
|
||||
(self?.tableView.bounds.contains(headerRect) == true)
|
||||
}
|
||||
|
||||
guard shouldLoadMore else { return }
|
||||
|
||||
self?.isLoadingMore = true
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||
self?.viewModel.pagedDataObserver?.load(.pageAfter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UITableViewDataSource
|
||||
|
||||
func numberOfSections(in tableView: UITableView) -> Int {
|
||||
return viewModel.threadData.count
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
let section: MessageRequestsViewModel.SectionModel = viewModel.threadData[section]
|
||||
|
||||
return section.elements.count
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
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.accessibilityIdentifier = "Message request"
|
||||
cell.isAccessibilityElement = true
|
||||
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.themeTintColor = .textPrimary
|
||||
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.viewHasAppeared && !self.isLoadingMore else { return }
|
||||
|
||||
let section: MessageRequestsViewModel.SectionModel = self.viewModel.threadData[section]
|
||||
|
||||
switch section.model {
|
||||
case .loadMore:
|
||||
self.isLoadingMore = true
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).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: 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 {
|
||||
return true
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, willBeginEditingRowAt indexPath: IndexPath) {
|
||||
UIContextualAction.willBeginEditing(indexPath: indexPath, tableView: tableView)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, didEndEditingRowAt indexPath: IndexPath?) {
|
||||
UIContextualAction.didEndEditing(indexPath: indexPath, tableView: tableView)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
|
||||
let section: MessageRequestsViewModel.SectionModel = self.viewModel.threadData[indexPath.section]
|
||||
let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row]
|
||||
|
||||
switch section.model {
|
||||
case .threads:
|
||||
return UIContextualAction.configuration(
|
||||
for: UIContextualAction.generateSwipeActions(
|
||||
[
|
||||
(threadViewModel.threadVariant != .contact ? nil : .block),
|
||||
.delete
|
||||
].compactMap { $0 },
|
||||
for: .trailing,
|
||||
indexPath: indexPath,
|
||||
tableView: tableView,
|
||||
threadViewModel: threadViewModel,
|
||||
viewController: self
|
||||
)
|
||||
)
|
||||
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Interaction
|
||||
|
||||
@objc private func clearAllTapped() {
|
||||
guard viewModel.threadData.first(where: { $0.model == .threads })?.elements.isEmpty == false else {
|
||||
return
|
||||
}
|
||||
|
||||
let contactThreadIds: [String] = (viewModel.threadData
|
||||
.first { $0.model == .threads }?
|
||||
.elements
|
||||
.filter { $0.threadVariant == .contact }
|
||||
.map { $0.threadId })
|
||||
.defaulting(to: [])
|
||||
let groupThreadIds: [String] = (viewModel.threadData
|
||||
.first { $0.model == .threads }?
|
||||
.elements
|
||||
.filter { $0.threadVariant == .legacyGroup || $0.threadVariant == .group }
|
||||
.map { $0.threadId })
|
||||
.defaulting(to: [])
|
||||
let alertVC: UIAlertController = UIAlertController(
|
||||
title: "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_TITLE".localized(),
|
||||
message: nil,
|
||||
preferredStyle: .actionSheet
|
||||
)
|
||||
alertVC.addAction(UIAlertAction(
|
||||
title: "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_ACTON".localized(),
|
||||
style: .destructive
|
||||
) { _ in
|
||||
MessageRequestsViewModel.clearAllRequests(
|
||||
contactThreadIds: contactThreadIds,
|
||||
groupThreadIds: groupThreadIds
|
||||
)
|
||||
})
|
||||
alertVC.addAction(UIAlertAction(title: "TXT_CANCEL_TITLE".localized(), style: .cancel, handler: nil))
|
||||
|
||||
Modal.setupForIPadIfNeeded(alertVC, targetView: self.view)
|
||||
self.present(alertVC, animated: true, completion: nil)
|
||||
}
|
||||
}
|
@ -1,92 +0,0 @@
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
import SignalUtilitiesKit
|
||||
|
||||
class BlockedContactCell: UITableViewCell {
|
||||
// MARK: - Components
|
||||
|
||||
private lazy var profilePictureView: ProfilePictureView = ProfilePictureView(size: .list)
|
||||
|
||||
private let selectionView: RadioButton = {
|
||||
let result: RadioButton = RadioButton(size: .medium)
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.isUserInteractionEnabled = false
|
||||
result.font = .systemFont(ofSize: Values.mediumFontSize, weight: .bold)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: - Initializtion
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
|
||||
setUpViewHierarchy()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
|
||||
setUpViewHierarchy()
|
||||
}
|
||||
|
||||
// MARK: - Layout
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
// Background color
|
||||
themeBackgroundColor = .conversationButton_background
|
||||
|
||||
// Highlight color
|
||||
let selectedBackgroundView = UIView()
|
||||
selectedBackgroundView.themeBackgroundColor = .highlighted(.conversationButton_background)
|
||||
self.selectedBackgroundView = selectedBackgroundView
|
||||
|
||||
// Add the UI
|
||||
contentView.addSubview(profilePictureView)
|
||||
contentView.addSubview(selectionView)
|
||||
|
||||
setupLayout()
|
||||
}
|
||||
|
||||
private func setupLayout() {
|
||||
// Profile picture view
|
||||
profilePictureView.center(.vertical, in: contentView)
|
||||
profilePictureView.topAnchor
|
||||
.constraint(greaterThanOrEqualTo: contentView.topAnchor, constant: Values.mediumSpacing)
|
||||
.isActive = true
|
||||
profilePictureView.bottomAnchor
|
||||
.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -Values.mediumSpacing)
|
||||
.isActive = true
|
||||
profilePictureView.pin(.left, to: .left, of: contentView, withInset: Values.veryLargeSpacing)
|
||||
|
||||
selectionView.center(.vertical, in: contentView)
|
||||
selectionView.topAnchor
|
||||
.constraint(greaterThanOrEqualTo: contentView.topAnchor, constant: Values.mediumSpacing)
|
||||
.isActive = true
|
||||
selectionView.bottomAnchor
|
||||
.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -Values.mediumSpacing)
|
||||
.isActive = true
|
||||
selectionView.pin(.left, to: .right, of: profilePictureView, withInset: Values.mediumSpacing)
|
||||
selectionView.pin(.right, to: .right, of: contentView, withInset: -Values.veryLargeSpacing)
|
||||
}
|
||||
|
||||
// MARK: - Content
|
||||
|
||||
public func update(with cellViewModel: BlockedContactsViewModel.DataModel, isSelected: Bool) {
|
||||
profilePictureView.update(
|
||||
publicKey: cellViewModel.id,
|
||||
threadVariant: .contact,
|
||||
customImageData: nil,
|
||||
profile: cellViewModel.profile,
|
||||
additionalProfile: nil
|
||||
)
|
||||
selectionView.text = (
|
||||
cellViewModel.profile?.displayName() ??
|
||||
Profile.truncated(id: cellViewModel.id, truncating: .middle)
|
||||
)
|
||||
selectionView.update(isSelected: isSelected)
|
||||
}
|
||||
}
|
@ -0,0 +1,76 @@
|
||||
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import DifferenceKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
// MARK: - EditableStateHolder
|
||||
|
||||
public protocol EditableStateHolder: AnyObject, TableData, ErasedEditableStateHolder {
|
||||
var editableState: EditableState<TableItem> { get }
|
||||
}
|
||||
|
||||
public extension EditableStateHolder {
|
||||
var textChanged: AnyPublisher<(text: String?, item: TableItem), Never> { editableState.textChanged }
|
||||
|
||||
func setIsEditing(_ isEditing: Bool) {
|
||||
editableState._isEditing.send(isEditing)
|
||||
}
|
||||
|
||||
func textChanged(_ text: String?, for item: TableItem) {
|
||||
editableState._textChanged.send((text, item))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ErasedEditableStateHolder
|
||||
|
||||
public protocol ErasedEditableStateHolder: AnyObject {
|
||||
var isEditing: AnyPublisher<Bool, Never> { get }
|
||||
|
||||
func setIsEditing(_ isEditing: Bool)
|
||||
func textChanged<Item>(_ text: String?, for item: Item)
|
||||
}
|
||||
|
||||
public extension ErasedEditableStateHolder {
|
||||
var isEditing: AnyPublisher<Bool, Never> { Just(false).eraseToAnyPublisher() }
|
||||
|
||||
func setIsEditing(_ isEditing: Bool) {}
|
||||
func textChanged<Item>(_ text: String?, for item: Item) {}
|
||||
}
|
||||
|
||||
public extension ErasedEditableStateHolder where Self: EditableStateHolder {
|
||||
var isEditing: AnyPublisher<Bool, Never> { editableState.isEditing }
|
||||
|
||||
func setIsEditing(_ isEditing: Bool) {
|
||||
editableState._isEditing.send(isEditing)
|
||||
}
|
||||
|
||||
func textChanged<Item>(_ text: String?, for item: Item) {
|
||||
guard let convertedItem: TableItem = item as? TableItem else { return }
|
||||
|
||||
editableState._textChanged.send((text, convertedItem))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - EditableState
|
||||
|
||||
public struct EditableState<TableItem: Hashable & Differentiable> {
|
||||
let isEditing: AnyPublisher<Bool, Never>
|
||||
let textChanged: AnyPublisher<(text: String?, item: TableItem), Never>
|
||||
|
||||
// MARK: - Internal Variables
|
||||
|
||||
fileprivate let _isEditing: CurrentValueSubject<Bool, Never> = CurrentValueSubject(false)
|
||||
fileprivate let _textChanged: PassthroughSubject<(text: String?, item: TableItem), Never> = PassthroughSubject()
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init() {
|
||||
self.isEditing = _isEditing
|
||||
.removeDuplicates()
|
||||
.shareReplay(1)
|
||||
self.textChanged = _textChanged
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
@ -0,0 +1,71 @@
|
||||
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import SessionUIKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
// MARK: - NavigationItemSource
|
||||
|
||||
protocol NavigationItemSource {
|
||||
associatedtype NavItem: Equatable
|
||||
|
||||
var leftNavItems: AnyPublisher<[SessionNavItem<NavItem>], Never> { get }
|
||||
var rightNavItems: AnyPublisher<[SessionNavItem<NavItem>], Never> { get }
|
||||
}
|
||||
|
||||
// MARK: - Defaults
|
||||
|
||||
extension NavigationItemSource {
|
||||
var leftNavItems: AnyPublisher<[SessionNavItem<NavItem>], Never> { Just([]).eraseToAnyPublisher() }
|
||||
var rightNavItems: AnyPublisher<[SessionNavItem<NavItem>], Never> { Just([]).eraseToAnyPublisher() }
|
||||
}
|
||||
|
||||
// MARK: - Bindings
|
||||
|
||||
extension NavigationItemSource {
|
||||
func setupBindings(
|
||||
viewController: UIViewController,
|
||||
disposables: inout Set<AnyCancellable>
|
||||
) {
|
||||
self.leftNavItems
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak viewController] items in
|
||||
viewController?.navigationItem.setLeftBarButtonItems(
|
||||
items.map { item -> DisposableBarButtonItem in
|
||||
let buttonItem: DisposableBarButtonItem = item.createBarButtonItem()
|
||||
buttonItem.themeTintColor = .textPrimary
|
||||
|
||||
buttonItem.tapPublisher
|
||||
.map { _ in item.id }
|
||||
.sink(receiveValue: { _ in item.action?() })
|
||||
.store(in: &buttonItem.disposables)
|
||||
|
||||
return buttonItem
|
||||
},
|
||||
animated: true
|
||||
)
|
||||
}
|
||||
.store(in: &disposables)
|
||||
|
||||
self.rightNavItems
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak viewController] items in
|
||||
viewController?.navigationItem.setRightBarButtonItems(
|
||||
items.map { item -> DisposableBarButtonItem in
|
||||
let buttonItem: DisposableBarButtonItem = item.createBarButtonItem()
|
||||
buttonItem.themeTintColor = .textPrimary
|
||||
|
||||
buttonItem.tapPublisher
|
||||
.map { _ in item.id }
|
||||
.sink(receiveValue: { _ in item.action?() })
|
||||
.store(in: &buttonItem.disposables)
|
||||
|
||||
return buttonItem
|
||||
},
|
||||
animated: true
|
||||
)
|
||||
}
|
||||
.store(in: &disposables)
|
||||
}
|
||||
}
|
@ -0,0 +1,111 @@
|
||||
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import Combine
|
||||
import SessionUIKit
|
||||
import SessionUtilitiesKit
|
||||
import SignalUtilitiesKit
|
||||
|
||||
// MARK: - NavigatableStateHolder
|
||||
|
||||
public protocol NavigatableStateHolder {
|
||||
var navigatableState: NavigatableState { get }
|
||||
}
|
||||
|
||||
public extension NavigatableStateHolder {
|
||||
func showToast(text: String, backgroundColor: ThemeValue = .backgroundPrimary) {
|
||||
navigatableState._showToast.send((text, backgroundColor))
|
||||
}
|
||||
|
||||
func dismissScreen(type: DismissType = .auto) {
|
||||
navigatableState._dismissScreen.send(type)
|
||||
}
|
||||
|
||||
func transitionToScreen(_ viewController: UIViewController, transitionType: TransitionType = .push) {
|
||||
navigatableState._transitionToScreen.send((viewController, transitionType))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - NavigatableState
|
||||
|
||||
public struct NavigatableState {
|
||||
let showToast: AnyPublisher<(String, ThemeValue), Never>
|
||||
let transitionToScreen: AnyPublisher<(UIViewController, TransitionType), Never>
|
||||
let dismissScreen: AnyPublisher<DismissType, Never>
|
||||
|
||||
// MARK: - Internal Variables
|
||||
|
||||
fileprivate let _showToast: PassthroughSubject<(String, ThemeValue), Never> = PassthroughSubject()
|
||||
fileprivate let _transitionToScreen: PassthroughSubject<(UIViewController, TransitionType), Never> = PassthroughSubject()
|
||||
fileprivate let _dismissScreen: PassthroughSubject<DismissType, Never> = PassthroughSubject()
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init() {
|
||||
self.showToast = _showToast.shareReplay(0)
|
||||
self.transitionToScreen = _transitionToScreen.shareReplay(0)
|
||||
self.dismissScreen = _dismissScreen.shareReplay(0)
|
||||
}
|
||||
|
||||
// MARK: - Functions
|
||||
|
||||
public func setupBindings(
|
||||
viewController: UIViewController,
|
||||
disposables: inout Set<AnyCancellable>
|
||||
) {
|
||||
self.showToast
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak viewController] text, color in
|
||||
guard let view: UIView = viewController?.view else { return }
|
||||
|
||||
let toastController: ToastController = ToastController(text: text, background: color)
|
||||
toastController.presentToastView(fromBottomOfView: view, inset: Values.largeSpacing)
|
||||
}
|
||||
.store(in: &disposables)
|
||||
|
||||
self.transitionToScreen
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak viewController] targetViewController, transitionType in
|
||||
switch transitionType {
|
||||
case .push:
|
||||
viewController?.navigationController?.pushViewController(targetViewController, animated: true)
|
||||
|
||||
case .present:
|
||||
let presenter: UIViewController? = (viewController?.presentedViewController ?? viewController)
|
||||
|
||||
if UIDevice.current.isIPad {
|
||||
targetViewController.popoverPresentationController?.permittedArrowDirections = []
|
||||
targetViewController.popoverPresentationController?.sourceView = presenter?.view
|
||||
targetViewController.popoverPresentationController?.sourceRect = (presenter?.view.bounds ?? UIScreen.main.bounds)
|
||||
}
|
||||
|
||||
presenter?.present(targetViewController, animated: true)
|
||||
}
|
||||
}
|
||||
.store(in: &disposables)
|
||||
|
||||
self.dismissScreen
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak viewController] dismissType in
|
||||
switch dismissType {
|
||||
case .auto:
|
||||
guard
|
||||
let viewController: UIViewController = viewController,
|
||||
(viewController.navigationController?.viewControllers
|
||||
.firstIndex(of: viewController))
|
||||
.defaulting(to: 0) > 0
|
||||
else {
|
||||
viewController?.dismiss(animated: true)
|
||||
return
|
||||
}
|
||||
|
||||
viewController.navigationController?.popViewController(animated: true)
|
||||
|
||||
case .dismiss: viewController?.dismiss(animated: true)
|
||||
case .pop: viewController?.navigationController?.popViewController(animated: true)
|
||||
case .popToRoot: viewController?.navigationController?.popToRootViewController(animated: true)
|
||||
}
|
||||
}
|
||||
.store(in: &disposables)
|
||||
}
|
||||
}
|
@ -0,0 +1,263 @@
|
||||
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import GRDB
|
||||
import Combine
|
||||
import DifferenceKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
// MARK: - ObservableTableSource
|
||||
|
||||
public protocol ObservableTableSource: AnyObject, SectionedTableData {
|
||||
typealias TargetObservation = TableObservation<[SectionModel]>
|
||||
typealias TargetPublisher = AnyPublisher<(([SectionModel], StagedChangeset<[SectionModel]>)), Error>
|
||||
|
||||
var dependencies: Dependencies { get }
|
||||
var state: TableDataState<Section, TableItem> { get }
|
||||
var observableState: ObservableTableSourceState<Section, TableItem> { get }
|
||||
var observation: TargetObservation { get }
|
||||
|
||||
// MARK: - Functions
|
||||
|
||||
func didReturnFromBackground()
|
||||
}
|
||||
|
||||
extension ObservableTableSource {
|
||||
public var pendingTableDataSubject: CurrentValueSubject<([SectionModel], StagedChangeset<[SectionModel]>), Never> {
|
||||
self.observableState.pendingTableDataSubject
|
||||
}
|
||||
public var observation: TargetObservation {
|
||||
ObservationBuilder.changesetSubject(self.observableState.pendingTableDataSubject)
|
||||
}
|
||||
|
||||
public var tableDataPublisher: TargetPublisher { self.observation.finalPublisher(self, using: dependencies) }
|
||||
|
||||
public func didReturnFromBackground() {}
|
||||
public func forceRefresh() { self.observableState._forcedRefresh.send(()) }
|
||||
}
|
||||
|
||||
// MARK: - State Manager (ObservableTableSource)
|
||||
|
||||
public class ObservableTableSourceState<Section: SessionTableSection, TableItem: Hashable & Differentiable>: SectionedTableData {
|
||||
public let forcedRefresh: AnyPublisher<Void, Never>
|
||||
public let pendingTableDataSubject: CurrentValueSubject<([SectionModel], StagedChangeset<[SectionModel]>), Never>
|
||||
|
||||
// MARK: - Internal Variables
|
||||
|
||||
fileprivate var hasEmittedInitialData: Bool
|
||||
fileprivate let _forcedRefresh: PassthroughSubject<Void, Never> = PassthroughSubject()
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init() {
|
||||
self.hasEmittedInitialData = false
|
||||
self.forcedRefresh = _forcedRefresh.shareReplay(0)
|
||||
self.pendingTableDataSubject = CurrentValueSubject(([], StagedChangeset()))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - TableObservation
|
||||
|
||||
public struct TableObservation<T> {
|
||||
fileprivate let generatePublisher: (any ObservableTableSource, Dependencies) -> AnyPublisher<T, Error>
|
||||
fileprivate let generatePublisherWithChangeset: ((any ObservableTableSource, Dependencies) -> AnyPublisher<Any, Error>)?
|
||||
|
||||
init(generatePublisher: @escaping (any ObservableTableSource, Dependencies) -> AnyPublisher<T, Error>) {
|
||||
self.generatePublisher = generatePublisher
|
||||
self.generatePublisherWithChangeset = nil
|
||||
}
|
||||
|
||||
init(generatePublisherWithChangeset: @escaping (any ObservableTableSource, Dependencies) -> AnyPublisher<(T, StagedChangeset<T>), Error>) where T: Collection {
|
||||
self.generatePublisher = { _, _ in Fail(error: StorageError.invalidData).eraseToAnyPublisher() }
|
||||
self.generatePublisherWithChangeset = { source, dependencies in
|
||||
generatePublisherWithChangeset(source, dependencies).map { $0 as Any }.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func finalPublisher<S: ObservableTableSource>(
|
||||
_ source: S,
|
||||
using dependencies: Dependencies
|
||||
) -> S.TargetPublisher {
|
||||
typealias TargetData = (([S.SectionModel], StagedChangeset<[S.SectionModel]>))
|
||||
|
||||
switch (self, self.generatePublisherWithChangeset) {
|
||||
case (_, .some(let generatePublisherWithChangeset)):
|
||||
return generatePublisherWithChangeset(source, dependencies)
|
||||
.tryMap { data -> TargetData in
|
||||
guard let convertedData: TargetData = data as? TargetData else {
|
||||
throw StorageError.invalidData
|
||||
}
|
||||
|
||||
return convertedData
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
|
||||
case (let validObservation as S.TargetObservation, _):
|
||||
// Doing `removeDuplicates` in case the conversion from the original data to [SectionModel]
|
||||
// can result in duplicate output even with some different inputs
|
||||
return validObservation.generatePublisher(source, dependencies)
|
||||
.removeDuplicates()
|
||||
.mapToSessionTableViewData(for: source)
|
||||
|
||||
default: return Fail(error: StorageError.invalidData).eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension TableObservation: ExpressibleByArrayLiteral where T: Collection {
|
||||
public init(arrayLiteral elements: T.Element?...) {
|
||||
self.init(
|
||||
generatePublisher: { _, _ in
|
||||
guard let convertedElements: T = Array(elements.compactMap { $0 }) as? T else {
|
||||
return Fail(error: StorageError.invalidData).eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
return Just(convertedElements)
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ObservationBuilder
|
||||
|
||||
public enum ObservationBuilder {
|
||||
/// The `subject` will emit immediately when there is a subscriber and store the most recent value to be emitted whenever a new subscriber is
|
||||
/// added
|
||||
static func subject<T: Equatable>(_ subject: CurrentValueSubject<T, Error>) -> TableObservation<T> {
|
||||
return TableObservation { _, _ in
|
||||
return subject
|
||||
.removeDuplicates()
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
||||
/// The `subject` will emit immediately when there is a subscriber and store the most recent value to be emitted whenever a new subscriber is
|
||||
/// added
|
||||
static func subject<T: Equatable>(_ subject: CurrentValueSubject<T, Never>) -> TableObservation<T> {
|
||||
return TableObservation { _, _ in
|
||||
return subject
|
||||
.removeDuplicates()
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
||||
/// The `ValueObserveration` will trigger whenever any of the data fetched in the closure is updated, please see the following link for tips
|
||||
/// to help optimise performance https://github.com/groue/GRDB.swift#valueobservation-performance
|
||||
static func databaseObservation<S: ObservableTableSource, T: Equatable>(_ source: S, fetch: @escaping (Database) throws -> T) -> TableObservation<T> {
|
||||
/// **Note:** This observation will be triggered twice immediately (and be de-duped by the `removeDuplicates`)
|
||||
/// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own
|
||||
/// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`)
|
||||
/// just in case the database has changed between the two reads - unfortunately it doesn't look like there is a way to prevent this
|
||||
return TableObservation { viewModel, dependencies in
|
||||
return ValueObservation
|
||||
.trackingConstantRegion(fetch)
|
||||
.removeDuplicates()
|
||||
.handleEvents(didFail: { SNLog("[\(type(of: viewModel))] Observation failed with error: \($0)") })
|
||||
.publisher(in: dependencies.storage, scheduling: dependencies.scheduler)
|
||||
.manualRefreshFrom(source.observableState.forcedRefresh)
|
||||
}
|
||||
}
|
||||
|
||||
/// The `ValueObserveration` will trigger whenever any of the data fetched in the closure is updated, please see the following link for tips
|
||||
/// to help optimise performance https://github.com/groue/GRDB.swift#valueobservation-performance
|
||||
static func databaseObservation<S: ObservableTableSource, T: Equatable>(_ source: S, fetch: @escaping (Database) throws -> [T]) -> TableObservation<[T]> {
|
||||
/// **Note:** This observation will be triggered twice immediately (and be de-duped by the `removeDuplicates`)
|
||||
/// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own
|
||||
/// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`)
|
||||
/// just in case the database has changed between the two reads - unfortunately it doesn't look like there is a way to prevent this
|
||||
return TableObservation { viewModel, dependencies in
|
||||
return ValueObservation
|
||||
.trackingConstantRegion(fetch)
|
||||
.removeDuplicates()
|
||||
.handleEvents(didFail: { SNLog("[\(type(of: viewModel))] Observation failed with error: \($0)") })
|
||||
.publisher(in: dependencies.storage, scheduling: dependencies.scheduler)
|
||||
.manualRefreshFrom(source.observableState.forcedRefresh)
|
||||
}
|
||||
}
|
||||
|
||||
/// The `changesetSubject` will emit immediately when there is a subscriber and store the most recent value to be emitted whenever a new subscriber is
|
||||
/// added
|
||||
static func changesetSubject<T>(
|
||||
_ subject: CurrentValueSubject<([T], StagedChangeset<[T]>), Never>
|
||||
) -> TableObservation<[T]> {
|
||||
return TableObservation { viewModel, dependencies in
|
||||
subject
|
||||
.withPrevious(([], StagedChangeset()))
|
||||
.filter { prev, next in
|
||||
/// Suppress events with no changes (these will be sent in order to clear out the `StagedChangeset` value as if we
|
||||
/// don't do so then resubscribing will result in an attempt to apply an invalid changeset to the `tableView` resulting
|
||||
/// in a crash)
|
||||
!next.1.isEmpty
|
||||
}
|
||||
.map { _, current -> ([T], StagedChangeset<[T]>) in current }
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Convenience Transforms
|
||||
|
||||
public extension TableObservation {
|
||||
func map<R>(transform: @escaping (T) -> R) -> TableObservation<R> {
|
||||
return TableObservation<R> { viewModel, dependencies in
|
||||
self.generatePublisher(viewModel, dependencies).map(transform).eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
||||
func mapWithPrevious<R>(transform: @escaping (T?, T) -> R) -> TableObservation<R> {
|
||||
return TableObservation<R> { viewModel, dependencies in
|
||||
self.generatePublisher(viewModel, dependencies)
|
||||
.withPrevious()
|
||||
.map(transform)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public extension Array {
|
||||
func mapToSessionTableViewData<S: ObservableTableSource>(
|
||||
for source: S?
|
||||
) -> [ArraySection<S.Section, SessionCell.Info<S.TableItem>>] where Element == ArraySection<S.Section, SessionCell.Info<S.TableItem>> {
|
||||
// Update the data to include the proper position for each element
|
||||
return self.map { section in
|
||||
ArraySection(
|
||||
model: section.model,
|
||||
elements: section.elements.enumerated().map { index, element in
|
||||
element.updatedPosition(for: index, count: section.elements.count)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public extension Publisher {
|
||||
func mapToSessionTableViewData<S: ObservableTableSource>(
|
||||
for source: S
|
||||
) -> AnyPublisher<(Output, StagedChangeset<Output>), Failure> where Output == [ArraySection<S.Section, SessionCell.Info<S.TableItem>>] {
|
||||
return self
|
||||
.map { [weak source] updatedData -> (Output, StagedChangeset<Output>) in
|
||||
let updatedDataWithPositions: Output = updatedData
|
||||
.mapToSessionTableViewData(for: source)
|
||||
|
||||
// Generate an updated changeset
|
||||
let changeset = StagedChangeset(
|
||||
source: (source?.state.tableData ?? []),
|
||||
target: updatedDataWithPositions
|
||||
)
|
||||
|
||||
return (updatedDataWithPositions, changeset)
|
||||
}
|
||||
.filter { [weak source] _, changeset in
|
||||
source?.observableState.hasEmittedInitialData == false || // Always emit at least once
|
||||
!changeset.isEmpty // Do nothing if there were no changes
|
||||
}
|
||||
.handleEvents(receiveOutput: { [weak source] _ in
|
||||
source?.observableState.hasEmittedInitialData = true
|
||||
})
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
@ -0,0 +1,27 @@
|
||||
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import GRDB
|
||||
import SessionUtilitiesKit
|
||||
|
||||
protocol PagedObservationSource {
|
||||
associatedtype PagedTable: TableRecord & ColumnExpressible & Identifiable
|
||||
associatedtype PagedDataModel: FetchableRecordWithRowId & Identifiable
|
||||
|
||||
var pagedDataObserver: PagedDatabaseObserver<PagedTable, PagedDataModel>? { get }
|
||||
|
||||
func didInit(using dependencies: Dependencies)
|
||||
func loadPageBefore()
|
||||
func loadPageAfter()
|
||||
}
|
||||
|
||||
extension PagedObservationSource {
|
||||
public func didInit(using dependencies: Dependencies) {
|
||||
dependencies.storage.addObserver(pagedDataObserver)
|
||||
}
|
||||
}
|
||||
|
||||
extension PagedObservationSource where PagedTable.ID: SQLExpressible {
|
||||
func loadPageBefore() { pagedDataObserver?.load(.pageBefore) }
|
||||
func loadPageAfter() { pagedDataObserver?.load(.pageAfter) }
|
||||
}
|
@ -0,0 +1,87 @@
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
public struct SessionNavItem<Id: Equatable>: Equatable {
|
||||
let id: Id
|
||||
let image: UIImage?
|
||||
let style: UIBarButtonItem.Style
|
||||
let systemItem: UIBarButtonItem.SystemItem?
|
||||
let accessibilityIdentifier: String
|
||||
let accessibilityLabel: String?
|
||||
let action: (() -> Void)?
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
public init(
|
||||
id: Id,
|
||||
systemItem: UIBarButtonItem.SystemItem?,
|
||||
accessibilityIdentifier: String,
|
||||
accessibilityLabel: String? = nil,
|
||||
action: (() -> Void)? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.image = nil
|
||||
self.style = .plain
|
||||
self.systemItem = systemItem
|
||||
self.accessibilityIdentifier = accessibilityIdentifier
|
||||
self.accessibilityLabel = accessibilityLabel
|
||||
self.action = action
|
||||
}
|
||||
|
||||
public init(
|
||||
id: Id,
|
||||
image: UIImage?,
|
||||
style: UIBarButtonItem.Style,
|
||||
accessibilityIdentifier: String,
|
||||
accessibilityLabel: String? = nil,
|
||||
action: (() -> Void)? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.image = image
|
||||
self.style = style
|
||||
self.systemItem = nil
|
||||
self.accessibilityIdentifier = accessibilityIdentifier
|
||||
self.accessibilityLabel = accessibilityLabel
|
||||
self.action = action
|
||||
}
|
||||
|
||||
// MARK: - Functions
|
||||
|
||||
public func createBarButtonItem() -> DisposableBarButtonItem {
|
||||
guard let systemItem: UIBarButtonItem.SystemItem = systemItem else {
|
||||
return DisposableBarButtonItem(
|
||||
image: image,
|
||||
style: style,
|
||||
target: nil,
|
||||
action: nil,
|
||||
accessibilityIdentifier: accessibilityIdentifier,
|
||||
accessibilityLabel: accessibilityLabel
|
||||
)
|
||||
}
|
||||
|
||||
return DisposableBarButtonItem(
|
||||
barButtonSystemItem: systemItem,
|
||||
target: nil,
|
||||
action: nil,
|
||||
accessibilityIdentifier: accessibilityIdentifier,
|
||||
accessibilityLabel: accessibilityLabel
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Conformance
|
||||
|
||||
public static func == (
|
||||
lhs: SessionNavItem<Id>,
|
||||
rhs: SessionNavItem<Id>
|
||||
) -> Bool {
|
||||
return (
|
||||
lhs.id == rhs.id &&
|
||||
lhs.image == rhs.image &&
|
||||
lhs.style == rhs.style &&
|
||||
lhs.systemItem == rhs.systemItem &&
|
||||
lhs.accessibilityIdentifier == rhs.accessibilityIdentifier
|
||||
)
|
||||
}
|
||||
}
|
@ -1,91 +0,0 @@
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
public enum NoNav: Equatable {}
|
||||
|
||||
extension SessionTableViewModel {
|
||||
public struct NavItem: Equatable {
|
||||
let id: NavItemId
|
||||
let image: UIImage?
|
||||
let style: UIBarButtonItem.Style
|
||||
let systemItem: UIBarButtonItem.SystemItem?
|
||||
let accessibilityIdentifier: String
|
||||
let accessibilityLabel: String?
|
||||
let action: (() -> Void)?
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
public init(
|
||||
id: NavItemId,
|
||||
systemItem: UIBarButtonItem.SystemItem?,
|
||||
accessibilityIdentifier: String,
|
||||
accessibilityLabel: String? = nil,
|
||||
action: (() -> Void)? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.image = nil
|
||||
self.style = .plain
|
||||
self.systemItem = systemItem
|
||||
self.accessibilityIdentifier = accessibilityIdentifier
|
||||
self.accessibilityLabel = accessibilityLabel
|
||||
self.action = action
|
||||
}
|
||||
|
||||
public init(
|
||||
id: NavItemId,
|
||||
image: UIImage?,
|
||||
style: UIBarButtonItem.Style,
|
||||
accessibilityIdentifier: String,
|
||||
accessibilityLabel: String? = nil,
|
||||
action: (() -> Void)? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.image = image
|
||||
self.style = style
|
||||
self.systemItem = nil
|
||||
self.accessibilityIdentifier = accessibilityIdentifier
|
||||
self.accessibilityLabel = accessibilityLabel
|
||||
self.action = action
|
||||
}
|
||||
|
||||
// MARK: - Functions
|
||||
|
||||
public func createBarButtonItem() -> DisposableBarButtonItem {
|
||||
guard let systemItem: UIBarButtonItem.SystemItem = systemItem else {
|
||||
return DisposableBarButtonItem(
|
||||
image: image,
|
||||
style: style,
|
||||
target: nil,
|
||||
action: nil,
|
||||
accessibilityIdentifier: accessibilityIdentifier,
|
||||
accessibilityLabel: accessibilityLabel
|
||||
)
|
||||
}
|
||||
|
||||
return DisposableBarButtonItem(
|
||||
barButtonSystemItem: systemItem,
|
||||
target: nil,
|
||||
action: nil,
|
||||
accessibilityIdentifier: accessibilityIdentifier,
|
||||
accessibilityLabel: accessibilityLabel
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Conformance
|
||||
|
||||
public static func == (
|
||||
lhs: SessionTableViewModel<NavItemId, Section, SettingItem>.NavItem,
|
||||
rhs: SessionTableViewModel<NavItemId, Section, SettingItem>.NavItem
|
||||
) -> Bool {
|
||||
return (
|
||||
lhs.id == rhs.id &&
|
||||
lhs.image == rhs.image &&
|
||||
lhs.style == rhs.style &&
|
||||
lhs.systemItem == rhs.systemItem &&
|
||||
lhs.accessibilityIdentifier == rhs.accessibilityIdentifier
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,20 @@
|
||||
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import DifferenceKit
|
||||
|
||||
public protocol TableData {
|
||||
associatedtype TableItem: Hashable & Differentiable
|
||||
}
|
||||
|
||||
public protocol SectionedTableData: TableData {
|
||||
associatedtype Section: SessionTableSection
|
||||
|
||||
typealias SectionModel = ArraySection<Section, SessionCell.Info<TableItem>>
|
||||
}
|
||||
|
||||
public class TableDataState<Section: SessionTableSection, TableItem: Hashable & Differentiable>: SectionedTableData {
|
||||
public private(set) var tableData: [SectionModel] = []
|
||||
|
||||
public func updateTableData(_ updatedData: [SectionModel]) { self.tableData = updatedData }
|
||||
}
|
@ -0,0 +1,72 @@
|
||||
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import GRDB
|
||||
import SessionUtilitiesKit
|
||||
|
||||
enum _005_AddSnodeReveivedMessageInfoPrimaryKey: Migration {
|
||||
static let target: TargetMigrations.Identifier = .snodeKit
|
||||
static let identifier: String = "AddSnodeReveivedMessageInfoPrimaryKey" // stringlint:disable
|
||||
static let needsConfigSync: Bool = false
|
||||
static let fetchedTables: [(TableRecord & FetchableRecord).Type] = [SnodeReceivedMessageInfo.self]
|
||||
static let createdOrAlteredTables: [(TableRecord & FetchableRecord).Type] = [SnodeReceivedMessageInfo.self]
|
||||
|
||||
/// This migration adds a flat to the `SnodeReceivedMessageInfo` so that when deleting interactions we can
|
||||
/// ignore their hashes when subsequently trying to fetch new messages (which results in the storage server returning
|
||||
/// messages from the beginning of time)
|
||||
static let minExpectedRunDuration: TimeInterval = 0.2
|
||||
|
||||
static func migrate(_ db: Database) throws {
|
||||
// SQLite doesn't support adding a new primary key after creation so we need to create a new table with
|
||||
// the setup we want, copy data from the old table over, drop the old table and rename the new table
|
||||
struct TmpSnodeReceivedMessageInfo: Codable, TableRecord, FetchableRecord, PersistableRecord, ColumnExpressible {
|
||||
static var databaseTableName: String { "tmpSnodeReceivedMessageInfo" }
|
||||
|
||||
typealias Columns = CodingKeys
|
||||
enum CodingKeys: String, CodingKey, ColumnExpression {
|
||||
case key
|
||||
case hash
|
||||
case expirationDateMs
|
||||
case wasDeletedOrInvalid
|
||||
}
|
||||
|
||||
let key: String
|
||||
let hash: String
|
||||
let expirationDateMs: Int64
|
||||
var wasDeletedOrInvalid: Bool?
|
||||
}
|
||||
|
||||
try db.create(table: TmpSnodeReceivedMessageInfo.self) { t in
|
||||
t.column(.key, .text).notNull()
|
||||
t.column(.hash, .text).notNull()
|
||||
t.column(.expirationDateMs, .integer).notNull()
|
||||
t.column(.wasDeletedOrInvalid, .boolean)
|
||||
|
||||
t.primaryKey([.key, .hash])
|
||||
}
|
||||
|
||||
// Insert into the new table, drop the old table and rename the new table to be the old one
|
||||
let tmpInfo: TypedTableAlias<TmpSnodeReceivedMessageInfo> = TypedTableAlias()
|
||||
let info: TypedTableAlias<SnodeReceivedMessageInfo> = TypedTableAlias()
|
||||
try db.execute(literal: """
|
||||
INSERT INTO \(tmpInfo)
|
||||
SELECT \(info[.key]), \(info[.hash]), \(info[.expirationDateMs]), \(info[.wasDeletedOrInvalid])
|
||||
FROM \(info)
|
||||
""")
|
||||
|
||||
try db.drop(table: SnodeReceivedMessageInfo.self)
|
||||
try db.rename(
|
||||
table: TmpSnodeReceivedMessageInfo.databaseTableName,
|
||||
to: SnodeReceivedMessageInfo.databaseTableName
|
||||
)
|
||||
|
||||
// Need to create the indexes separately from creating 'TmpGroupMember' to ensure they
|
||||
// have the correct names
|
||||
try db.createIndex(on: SnodeReceivedMessageInfo.self, columns: [.key])
|
||||
try db.createIndex(on: SnodeReceivedMessageInfo.self, columns: [.hash])
|
||||
try db.createIndex(on: SnodeReceivedMessageInfo.self, columns: [.expirationDateMs])
|
||||
try db.createIndex(on: SnodeReceivedMessageInfo.self, columns: [.wasDeletedOrInvalid])
|
||||
|
||||
Storage.update(progress: 1, for: self, in: target)
|
||||
}
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import DifferenceKit
|
||||
|
||||
extension String: Differentiable {}
|
Loading…
Reference in New Issue