Added in missing code changes unrelated to closed groups rebuild
This commit is contained in:
parent
70ff2b49f0
commit
f1e9412c7a
File diff suppressed because it is too large
Load Diff
|
@ -118,8 +118,10 @@ final class IncomingCallBanner: UIView, UIGestureRecognizerDelegate {
|
|||
|
||||
profilePictureView.update(
|
||||
publicKey: call.sessionId,
|
||||
threadVariant: .contact,
|
||||
customImageData: nil,
|
||||
profile: Profile.fetchOrCreate(id: call.sessionId),
|
||||
threadVariant: .contact
|
||||
additionalProfile: nil
|
||||
)
|
||||
displayNameLabel.text = call.contactName
|
||||
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import Combine
|
||||
import GRDB
|
||||
import DifferenceKit
|
||||
import PromiseKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
import SignalUtilitiesKit
|
||||
|
@ -220,7 +220,8 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat
|
|||
cell.update(
|
||||
with: SessionCell.Info(
|
||||
id: displayInfo,
|
||||
leftAccessory: .profile(displayInfo.profileId, displayInfo.profile),
|
||||
position: Position.with(indexPath.row, count: membersAndZombies.count),
|
||||
leftAccessory: .profile(id: displayInfo.profileId, profile: displayInfo.profile),
|
||||
title: (
|
||||
displayInfo.profile?.displayName() ??
|
||||
Profile.truncated(id: displayInfo.profileId, threadVariant: .contact)
|
||||
|
@ -231,10 +232,9 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat
|
|||
.withRenderingMode(.alwaysTemplate),
|
||||
customTint: .textSecondary
|
||||
)
|
||||
)
|
||||
),
|
||||
style: .edgeToEdge,
|
||||
position: Position.with(indexPath.row, count: membersAndZombies.count)
|
||||
),
|
||||
styling: SessionCell.StyleInfo(backgroundStyle: .edgeToEdge)
|
||||
)
|
||||
)
|
||||
|
||||
return cell
|
||||
|
@ -449,7 +449,7 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat
|
|||
|
||||
ModalActivityIndicatorViewController.present(fromViewController: navigationController) { _ in
|
||||
Storage.shared
|
||||
.writeAsync { db in
|
||||
.writePublisherFlatMap { db -> AnyPublisher<Void, Error> in
|
||||
if !updatedMemberIds.contains(userPublicKey) {
|
||||
return try MessageSender.leave(db, groupPublicKey: threadId)
|
||||
}
|
||||
|
@ -461,15 +461,20 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat
|
|||
name: updatedName
|
||||
)
|
||||
}
|
||||
.done(on: DispatchQueue.main) { [weak self] in
|
||||
self?.dismiss(animated: true, completion: nil) // Dismiss the loader
|
||||
popToConversationVC(self)
|
||||
}
|
||||
.catch(on: DispatchQueue.main) { [weak self] error in
|
||||
self?.dismiss(animated: true, completion: nil) // Dismiss the loader
|
||||
self?.showError(title: "GROUP_UPDATE_ERROR_TITLE".localized(), message: error.localizedDescription)
|
||||
}
|
||||
.retainUntilComplete()
|
||||
.sinkUntilComplete(
|
||||
receiveCompletion: { [weak self] result in
|
||||
self?.dismiss(animated: true, completion: nil) // Dismiss the loader
|
||||
|
||||
switch result {
|
||||
case .finished: popToConversationVC(self)
|
||||
case .failure(let error):
|
||||
self?.showError(
|
||||
title: "GROUP_UPDATE_ERROR_TITLE".localized(),
|
||||
message: error.localizedDescription
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -205,15 +205,17 @@ final class NewClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate
|
|||
cell.update(
|
||||
with: SessionCell.Info(
|
||||
id: profile,
|
||||
leftAccessory: .profile(profile.id, profile),
|
||||
position: Position.with(indexPath.row, count: data[indexPath.section].elements.count),
|
||||
leftAccessory: .profile(id: profile.id, profile: profile),
|
||||
title: profile.displayName(),
|
||||
rightAccessory: .radio(isSelected: { [weak self] in
|
||||
self?.selectedContacts.contains(profile.id) == true
|
||||
}),
|
||||
accessibilityIdentifier: "Contact"
|
||||
),
|
||||
style: .edgeToEdge,
|
||||
position: Position.with(indexPath.row, count: data[indexPath.section].elements.count)
|
||||
styling: SessionCell.StyleInfo(backgroundStyle: .edgeToEdge),
|
||||
accessibility: SessionCell.Accessibility(
|
||||
identifier: "Contact"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
return cell
|
||||
|
|
|
@ -1872,7 +1872,7 @@ extension ConversationVC:
|
|||
deleteRemotely(
|
||||
from: self,
|
||||
request: SnodeAPI
|
||||
.deleteMessage(
|
||||
.deleteMessages(
|
||||
publicKey: threadId,
|
||||
serverHashes: [serverHash]
|
||||
)
|
||||
|
@ -2328,10 +2328,11 @@ extension ConversationVC {
|
|||
)
|
||||
.save(db)
|
||||
|
||||
// Send a sync message with the details of the contact
|
||||
|
||||
// Update the config with the approved contact
|
||||
try MessageSender
|
||||
.syncConfiguration(db, forceSyncNow: true)
|
||||
.retainUntilComplete()
|
||||
.sinkUntilComplete()
|
||||
},
|
||||
completion: { _, _ in updateNavigationBackStack() }
|
||||
)
|
||||
|
@ -2347,79 +2348,31 @@ extension ConversationVC {
|
|||
}
|
||||
|
||||
@objc func deleteMessageRequest() {
|
||||
guard self.viewModel.threadData.threadVariant == .contact else { return }
|
||||
|
||||
let threadId: String = self.viewModel.threadData.threadId
|
||||
let alertVC: UIAlertController = UIAlertController(
|
||||
title: "MESSAGE_REQUESTS_DELETE_CONFIRMATION_ACTON".localized(),
|
||||
message: nil,
|
||||
preferredStyle: .actionSheet
|
||||
)
|
||||
alertVC.addAction(UIAlertAction(title: "TXT_DELETE_TITLE".localized(), style: .destructive) { _ in
|
||||
// Delete the request
|
||||
Storage.shared.writeAsync(
|
||||
updates: { db in
|
||||
_ = try SessionThread
|
||||
.filter(id: threadId)
|
||||
.deleteAll(db)
|
||||
},
|
||||
completion: { db, _ in
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.navigationController?.popViewController(animated: true)
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
alertVC.addAction(UIAlertAction(title: "TXT_CANCEL_TITLE".localized(), style: .cancel, handler: nil))
|
||||
|
||||
self.present(alertVC, animated: true, completion: nil)
|
||||
MessageRequestsViewModel.deleteMessageRequest(
|
||||
threadId: self.viewModel.threadData.threadId,
|
||||
threadVariant: self.viewModel.threadData.threadVariant,
|
||||
viewController: self
|
||||
) { [weak self] in
|
||||
self?.stopObservingChanges()
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self?.navigationController?.popViewController(animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func block() {
|
||||
guard self.viewModel.threadData.threadVariant == .contact else { return }
|
||||
|
||||
let threadId: String = self.viewModel.threadData.threadId
|
||||
let alertVC: UIAlertController = UIAlertController(
|
||||
title: "MESSAGE_REQUESTS_BLOCK_CONFIRMATION_ACTON".localized(),
|
||||
message: nil,
|
||||
preferredStyle: .actionSheet
|
||||
)
|
||||
alertVC.addAction(UIAlertAction(title: "BLOCK_LIST_BLOCK_BUTTON".localized(), style: .destructive) { _ in
|
||||
// Delete the request
|
||||
Storage.shared.writeAsync(
|
||||
updates: { db in
|
||||
// Update the contact
|
||||
_ = try Contact
|
||||
.fetchOrCreate(db, id: threadId)
|
||||
.with(
|
||||
isApproved: false,
|
||||
isBlocked: true,
|
||||
|
||||
// Note: We set this to true so the current user will be able to send a
|
||||
// message to the person who originally sent them the message request in
|
||||
// the future if they unblock them
|
||||
didApproveMe: true
|
||||
)
|
||||
.saved(db)
|
||||
|
||||
_ = try SessionThread
|
||||
.filter(id: threadId)
|
||||
.deleteAll(db)
|
||||
|
||||
try MessageSender
|
||||
.syncConfiguration(db, forceSyncNow: true)
|
||||
.retainUntilComplete()
|
||||
},
|
||||
completion: { db, _ in
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.navigationController?.popViewController(animated: true)
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
alertVC.addAction(UIAlertAction(title: "TXT_CANCEL_TITLE".localized(), style: .cancel, handler: nil))
|
||||
|
||||
self.present(alertVC, animated: true, completion: nil)
|
||||
@objc func blockMessageRequest() {
|
||||
MessageRequestsViewModel.blockMessageRequest(
|
||||
threadId: self.viewModel.threadData.threadId,
|
||||
threadVariant: self.viewModel.threadData.threadVariant,
|
||||
viewController: self
|
||||
) { [weak self] in
|
||||
self?.stopObservingChanges()
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self?.navigationController?.popViewController(animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -27,10 +27,6 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
|
|||
|
||||
var focusedInteractionId: Int64?
|
||||
var shouldHighlightNextScrollToInteraction: Bool = false
|
||||
var scrollButtonBottomConstraint: NSLayoutConstraint?
|
||||
var scrollButtonMessageRequestsBottomConstraint: NSLayoutConstraint?
|
||||
var scrollButtonPendingMessageRequestInfoBottomConstraint: NSLayoutConstraint?
|
||||
var messageRequestsViewBotomConstraint: NSLayoutConstraint?
|
||||
|
||||
// Search
|
||||
var isShowingSearchUI = false
|
||||
|
@ -40,8 +36,6 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
|
|||
var audioRecorder: AVAudioRecorder?
|
||||
var audioTimer: Timer?
|
||||
|
||||
private var searchBarWidth: NSLayoutConstraint?
|
||||
|
||||
// Context menu
|
||||
var contextMenuWindow: ContextMenuWindow?
|
||||
var contextMenuVC: ContextMenuVC?
|
||||
|
@ -129,6 +123,12 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
|
|||
|
||||
// MARK: - UI
|
||||
|
||||
var scrollButtonBottomConstraint: NSLayoutConstraint?
|
||||
var scrollButtonMessageRequestsBottomConstraint: NSLayoutConstraint?
|
||||
var scrollButtonPendingMessageRequestInfoBottomConstraint: NSLayoutConstraint?
|
||||
var messageRequestsViewBotomConstraint: NSLayoutConstraint?
|
||||
var messageRequestDescriptionLabelBottomConstraint: NSLayoutConstraint?
|
||||
|
||||
lazy var titleView: ConversationTitleView = {
|
||||
let result: ConversationTitleView = ConversationTitleView()
|
||||
let tapGestureRecognizer = UITapGestureRecognizer(
|
||||
|
@ -221,11 +221,22 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
|
|||
}()
|
||||
|
||||
lazy var scrollButton: ScrollToBottomButton = ScrollToBottomButton(delegate: self)
|
||||
|
||||
lazy var messageRequestView: UIView = {
|
||||
|
||||
lazy var messageRequestBackgroundView: UIView = {
|
||||
let result: UIView = UIView()
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.themeBackgroundColor = .backgroundPrimary
|
||||
result.isHidden = messageRequestStackView.isHidden
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
lazy var messageRequestStackView: UIStackView = {
|
||||
let result: UIStackView = UIStackView()
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.axis = .vertical
|
||||
result.alignment = .fill
|
||||
result.distribution = .fill
|
||||
result.isHidden = (
|
||||
self.viewModel.threadData.threadIsMessageRequest == false ||
|
||||
self.viewModel.threadData.threadRequiresApproval == true
|
||||
|
@ -233,18 +244,40 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
|
|||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var messageRequestDescriptionContainerView: UIView = {
|
||||
let result: UIView = UIView()
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private let messageRequestDescriptionLabel: UILabel = {
|
||||
private lazy var messageRequestDescriptionLabel: UILabel = {
|
||||
let result: UILabel = UILabel()
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.setContentCompressionResistancePriority(.required, for: .vertical)
|
||||
result.font = UIFont.systemFont(ofSize: 12)
|
||||
result.text = "MESSAGE_REQUESTS_INFO".localized()
|
||||
result.text = (self.viewModel.threadData.threadRequiresApproval == false ?
|
||||
"MESSAGE_REQUESTS_INFO".localized() :
|
||||
"MESSAGE_REQUEST_PENDING_APPROVAL_INFO".localized()
|
||||
)
|
||||
result.themeTextColor = .textSecondary
|
||||
result.textAlignment = .center
|
||||
result.numberOfLines = 0
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var messageRequestActionStackView: UIStackView = {
|
||||
let result: UIStackView = UIStackView()
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.axis = .horizontal
|
||||
result.alignment = .fill
|
||||
result.distribution = .fill
|
||||
result.spacing = (UIDevice.current.isIPad ? Values.iPadButtonSpacing : 20)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var messageRequestAcceptButton: UIButton = {
|
||||
let result: SessionButton = SessionButton(style: .bordered, size: .medium)
|
||||
|
@ -276,27 +309,11 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
|
|||
result.titleLabel?.font = UIFont.boldSystemFont(ofSize: 16)
|
||||
result.setTitle("TXT_BLOCK_USER_TITLE".localized(), for: .normal)
|
||||
result.setThemeTitleColor(.danger, for: .normal)
|
||||
result.addTarget(self, action: #selector(block), for: .touchUpInside)
|
||||
result.addTarget(self, action: #selector(blockMessageRequest), for: .touchUpInside)
|
||||
result.isHidden = (self.viewModel.threadData.threadVariant != .contact)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var pendingMessageRequestExplanationLabel: UILabel = {
|
||||
let result: UILabel = UILabel()
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.setContentCompressionResistancePriority(.required, for: .vertical)
|
||||
result.font = UIFont.systemFont(ofSize: 12)
|
||||
result.text = "MESSAGE_REQUEST_PENDING_APPROVAL_INFO".localized()
|
||||
result.themeTextColor = .textSecondary
|
||||
result.textAlignment = .center
|
||||
result.numberOfLines = 0
|
||||
result.isHidden = (
|
||||
!self.messageRequestStackView.isHidden ||
|
||||
self.viewModel.threadData.threadRequiresApproval == false
|
||||
)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: - Settings
|
||||
|
||||
|
@ -352,46 +369,32 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
|
|||
view.addSubview(scrollButton)
|
||||
view.addSubview(messageRequestBackgroundView)
|
||||
view.addSubview(messageRequestStackView)
|
||||
view.addSubview(pendingMessageRequestExplanationLabel)
|
||||
|
||||
messageRequestView.addSubview(messageRequestBlockButton)
|
||||
messageRequestView.addSubview(messageRequestDescriptionLabel)
|
||||
messageRequestView.addSubview(messageRequestAcceptButton)
|
||||
messageRequestView.addSubview(messageRequestDeleteButton)
|
||||
messageRequestStackView.addArrangedSubview(messageRequestBlockButton)
|
||||
messageRequestStackView.addArrangedSubview(messageRequestDescriptionContainerView)
|
||||
messageRequestStackView.addArrangedSubview(messageRequestActionStackView)
|
||||
messageRequestDescriptionContainerView.addSubview(messageRequestDescriptionLabel)
|
||||
messageRequestActionStackView.addArrangedSubview(messageRequestAcceptButton)
|
||||
messageRequestActionStackView.addArrangedSubview(messageRequestDeleteButton)
|
||||
|
||||
scrollButton.pin(.right, to: .right, of: view, withInset: -20)
|
||||
messageRequestView.pin(.left, to: .left, of: view)
|
||||
messageRequestView.pin(.right, to: .right, of: view)
|
||||
self.messageRequestsViewBotomConstraint = messageRequestView.pin(.bottom, to: .bottom, of: view, withInset: -16)
|
||||
scrollButton.pin(.trailing, to: .trailing, of: view, withInset: -20)
|
||||
messageRequestStackView.pin(.leading, to: .leading, of: view, withInset: 16)
|
||||
messageRequestStackView.pin(.trailing, to: .trailing, of: view, withInset: -16)
|
||||
self.messageRequestsViewBotomConstraint = messageRequestStackView.pin(.bottom, to: .bottom, of: view, withInset: -16)
|
||||
self.scrollButtonBottomConstraint = scrollButton.pin(.bottom, to: .bottom, of: view, withInset: -16)
|
||||
self.scrollButtonBottomConstraint?.isActive = false // Note: Need to disable this to avoid a conflict with the other bottom constraint
|
||||
self.scrollButtonMessageRequestsBottomConstraint = scrollButton.pin(.bottom, to: .top, of: messageRequestStackView)
|
||||
self.scrollButtonPendingMessageRequestInfoBottomConstraint = scrollButton.pin(.bottom, to: .top, of: pendingMessageRequestExplanationLabel, withInset: -16)
|
||||
self.scrollButtonMessageRequestsBottomConstraint = scrollButton.pin(.bottom, to: .top, of: messageRequestStackView, withInset: -4)
|
||||
|
||||
messageRequestBlockButton.pin(.top, to: .top, of: messageRequestView, withInset: 10)
|
||||
messageRequestBlockButton.center(.horizontal, in: messageRequestView)
|
||||
|
||||
messageRequestDescriptionLabel.pin(.top, to: .bottom, of: messageRequestBlockButton, withInset: 5)
|
||||
messageRequestDescriptionLabel.pin(.left, to: .left, of: messageRequestView, withInset: 40)
|
||||
messageRequestDescriptionLabel.pin(.right, to: .right, of: messageRequestView, withInset: -40)
|
||||
|
||||
messageRequestAcceptButton.pin(.top, to: .bottom, of: messageRequestDescriptionLabel, withInset: 20)
|
||||
messageRequestAcceptButton.pin(.left, to: .left, of: messageRequestView, withInset: 20)
|
||||
messageRequestAcceptButton.pin(.bottom, to: .bottom, of: messageRequestView)
|
||||
|
||||
messageRequestDeleteButton.pin(.top, to: .bottom, of: messageRequestDescriptionLabel, withInset: 20)
|
||||
messageRequestDeleteButton.pin(.left, to: .right, of: messageRequestAcceptButton, withInset: UIDevice.current.isIPad ? Values.iPadButtonSpacing : 20)
|
||||
messageRequestDeleteButton.pin(.right, to: .right, of: messageRequestView, withInset: -20)
|
||||
messageRequestDeleteButton.pin(.bottom, to: .bottom, of: messageRequestView)
|
||||
messageRequestDescriptionLabel.pin(.top, to: .top, of: messageRequestDescriptionContainerView, withInset: 4)
|
||||
messageRequestDescriptionLabel.pin(.leading, to: .leading, of: messageRequestDescriptionContainerView, withInset: 20)
|
||||
messageRequestDescriptionLabel.pin(.trailing, to: .trailing, of: messageRequestDescriptionContainerView, withInset: -20)
|
||||
self.messageRequestDescriptionLabelBottomConstraint = messageRequestDescriptionLabel.pin(.bottom, to: .bottom, of: messageRequestDescriptionContainerView, withInset: -20)
|
||||
messageRequestActionStackView.pin(.top, to: .bottom, of: messageRequestDescriptionContainerView)
|
||||
messageRequestDeleteButton.set(.width, to: .width, of: messageRequestAcceptButton)
|
||||
messageRequestBackgroundView.pin(.top, to: .top, of: messageRequestStackView)
|
||||
messageRequestBackgroundView.pin(.leading, to: .leading, of: view)
|
||||
messageRequestBackgroundView.pin(.trailing, to: .trailing, of: view)
|
||||
messageRequestBackgroundView.pin(.bottom, to: .bottom, of: view)
|
||||
|
||||
pendingMessageRequestExplanationLabel.pin(.left, to: .left, of: messageRequestStackView, withInset: 40)
|
||||
pendingMessageRequestExplanationLabel.pin(.right, to: .right, of: messageRequestStackView, withInset: -40)
|
||||
pendingMessageRequestExplanationLabel.pin(.bottom, to: .bottom, of: messageRequestStackView, withInset: -16)
|
||||
|
||||
// Unread count view
|
||||
view.addSubview(unreadCountView)
|
||||
|
@ -505,12 +508,6 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
|
|||
stopObservingChanges()
|
||||
}
|
||||
|
||||
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
|
||||
super.viewWillTransition(to: size, with: coordinator)
|
||||
searchBarWidth?.constant = size.width - 32
|
||||
tableView.reloadData()
|
||||
}
|
||||
|
||||
// MARK: - Updating
|
||||
|
||||
private func startObservingChanges(didReturnFromBackground: Bool = false) {
|
||||
|
@ -571,7 +568,7 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
|
|||
)
|
||||
}
|
||||
|
||||
private func stopObservingChanges() {
|
||||
func stopObservingChanges() {
|
||||
// Stop observing database changes
|
||||
dataChangeObservable?.cancel()
|
||||
self.viewModel.onInteractionChange = nil
|
||||
|
@ -619,6 +616,7 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
|
|||
|
||||
if
|
||||
initialLoad ||
|
||||
viewModel.threadData.threadVariant != updatedThreadData.threadVariant ||
|
||||
viewModel.threadData.threadRequiresApproval != updatedThreadData.threadRequiresApproval ||
|
||||
viewModel.threadData.threadIsMessageRequest != updatedThreadData.threadIsMessageRequest ||
|
||||
viewModel.threadData.profile != updatedThreadData.profile
|
||||
|
@ -628,47 +626,33 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
|
|||
let messageRequestsViewWasVisible: Bool = (
|
||||
messageRequestStackView.isHidden == false
|
||||
)
|
||||
let pendingMessageRequestInfoWasVisible: Bool = (
|
||||
pendingMessageRequestExplanationLabel.isHidden == false
|
||||
)
|
||||
|
||||
UIView.animate(withDuration: 0.3) { [weak self] in
|
||||
self?.messageRequestView.isHidden = (
|
||||
updatedThreadData.threadIsMessageRequest == false ||
|
||||
self?.messageRequestBlockButton.isHidden = (
|
||||
self?.viewModel.threadData.threadVariant != .contact ||
|
||||
updatedThreadData.threadRequiresApproval == true
|
||||
)
|
||||
self?.messageRequestBackgroundView.isHidden = (self?.messageRequestStackView.isHidden == true)
|
||||
self?.pendingMessageRequestExplanationLabel.isHidden = (
|
||||
self?.messageRequestStackView.isHidden == false ||
|
||||
self?.messageRequestActionStackView.isHidden = (
|
||||
updatedThreadData.threadRequiresApproval == true
|
||||
)
|
||||
self?.messageRequestStackView.isHidden = (
|
||||
updatedThreadData.threadIsMessageRequest == false &&
|
||||
updatedThreadData.threadRequiresApproval == false
|
||||
)
|
||||
self?.messageRequestBackgroundView.isHidden = (self?.messageRequestStackView.isHidden == true)
|
||||
self?.messageRequestDescriptionLabelBottomConstraint?.constant = (updatedThreadData.threadRequiresApproval == true ? -4 : -20)
|
||||
|
||||
self?.scrollButtonMessageRequestsBottomConstraint?.isActive = (
|
||||
self?.messageRequestStackView.isHidden == false
|
||||
)
|
||||
self?.scrollButtonPendingMessageRequestInfoBottomConstraint?.isActive = (
|
||||
self?.scrollButtonPendingMessageRequestInfoBottomConstraint?.isActive == false &&
|
||||
self?.pendingMessageRequestExplanationLabel.isHidden == false
|
||||
)
|
||||
self?.scrollButtonBottomConstraint?.isActive = (
|
||||
self?.scrollButtonMessageRequestsBottomConstraint?.isActive == false &&
|
||||
self?.scrollButtonPendingMessageRequestInfoBottomConstraint?.isActive == false
|
||||
self?.scrollButtonMessageRequestsBottomConstraint?.isActive == false
|
||||
)
|
||||
|
||||
// Update the table content inset and offset to account for
|
||||
// the dissapearance of the messageRequestsView
|
||||
if messageRequestsViewWasVisible {
|
||||
let messageRequestsOffset: CGFloat = ((self?.messageRequestView.bounds.height ?? 0) + 16)
|
||||
let oldContentInset: UIEdgeInsets = (self?.tableView.contentInset ?? UIEdgeInsets.zero)
|
||||
self?.tableView.contentInset = UIEdgeInsets(
|
||||
top: 0,
|
||||
leading: 0,
|
||||
bottom: max(oldContentInset.bottom - messageRequestsOffset, 0),
|
||||
trailing: 0
|
||||
)
|
||||
}
|
||||
else if pendingMessageRequestInfoWasVisible {
|
||||
let messageRequestsOffset: CGFloat = ((self?.pendingMessageRequestExplanationLabel.bounds.height ?? 0) + (16 * 2))
|
||||
if messageRequestsViewWasVisible != (self?.messageRequestStackView.isHidden == false) {
|
||||
let messageRequestsOffset: CGFloat = ((self?.messageRequestStackView.bounds.height ?? 0) + 12)
|
||||
let oldContentInset: UIEdgeInsets = (self?.tableView.contentInset ?? UIEdgeInsets.zero)
|
||||
self?.tableView.contentInset = UIEdgeInsets(
|
||||
top: 0,
|
||||
|
@ -1103,9 +1087,12 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
|
|||
profilePictureView.size = Values.verySmallProfilePictureSize
|
||||
profilePictureView.update(
|
||||
publicKey: threadData.threadId, // Contact thread uses the contactId
|
||||
threadVariant: threadData.threadVariant,
|
||||
customImageData: nil,
|
||||
profile: threadData.profile,
|
||||
threadVariant: threadData.threadVariant
|
||||
additionalProfile: nil
|
||||
)
|
||||
|
||||
profilePictureView.set(.width, to: (44 - 16)) // Width of the standard back button
|
||||
profilePictureView.set(.height, to: Values.verySmallProfilePictureSize)
|
||||
|
||||
|
@ -1159,7 +1146,7 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
|
|||
// needed for proper calculations, so force an initial layout if it doesn't have a size)
|
||||
var hasDoneLayout: Bool = true
|
||||
|
||||
if messageRequestView.bounds.height <= CGFloat.leastNonzeroMagnitude {
|
||||
if messageRequestStackView.bounds.height <= CGFloat.leastNonzeroMagnitude {
|
||||
hasDoneLayout = false
|
||||
|
||||
UIView.performWithoutAnimation {
|
||||
|
@ -1168,19 +1155,18 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
|
|||
}
|
||||
|
||||
let keyboardTop = (UIScreen.main.bounds.height - keyboardRect.minY)
|
||||
let messageRequestsOffset: CGFloat = (messageRequestStackView.isHidden ? 0 : messageRequestStackView.bounds.height + 16)
|
||||
let pendingMessageRequestsOffset: CGFloat = (pendingMessageRequestExplanationLabel.isHidden ? 0 : (pendingMessageRequestExplanationLabel.bounds.height + (16 * 2)))
|
||||
let messageRequestsOffset: CGFloat = (messageRequestStackView.isHidden ? 0 : messageRequestStackView.bounds.height + 12)
|
||||
let oldContentInset: UIEdgeInsets = tableView.contentInset
|
||||
let newContentInset: UIEdgeInsets = UIEdgeInsets(
|
||||
top: 0,
|
||||
leading: 0,
|
||||
bottom: (Values.mediumSpacing + keyboardTop + messageRequestsOffset + pendingMessageRequestsOffset),
|
||||
bottom: (Values.mediumSpacing + keyboardTop + messageRequestsOffset),
|
||||
trailing: 0
|
||||
)
|
||||
let newContentOffsetY: CGFloat = (tableView.contentOffset.y + (newContentInset.bottom - oldContentInset.bottom))
|
||||
let changes = { [weak self] in
|
||||
self?.scrollButtonBottomConstraint?.constant = -(keyboardTop + 16)
|
||||
self?.messageRequestsViewBotomConstraint?.constant = -(keyboardTop + 16)
|
||||
self?.scrollButtonBottomConstraint?.constant = -(keyboardTop + 12)
|
||||
self?.messageRequestsViewBotomConstraint?.constant = -(keyboardTop + 12)
|
||||
self?.tableView.contentInset = newContentInset
|
||||
self?.tableView.contentOffset.y = newContentOffsetY
|
||||
|
||||
|
@ -1226,8 +1212,8 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
|
|||
delay: 0,
|
||||
options: options,
|
||||
animations: { [weak self] in
|
||||
self?.scrollButtonBottomConstraint?.constant = -(keyboardTop + 16)
|
||||
self?.messageRequestsViewBotomConstraint?.constant = -(keyboardTop + 16)
|
||||
self?.scrollButtonBottomConstraint?.constant = -(keyboardTop + 12)
|
||||
self?.messageRequestsViewBotomConstraint?.constant = -(keyboardTop + 12)
|
||||
|
||||
let scrollButtonOpacity: CGFloat = (self?.getScrollButtonOpacity() ?? 0)
|
||||
self?.scrollButton.alpha = scrollButtonOpacity
|
||||
|
@ -1536,7 +1522,7 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
|
|||
searchBar.sizeToFit()
|
||||
searchBar.layoutMargins = UIEdgeInsets.zero
|
||||
searchBarContainer.set(.height, to: 44)
|
||||
searchBarWidth = searchBarContainer.set(.width, to: UIScreen.main.bounds.width - 32)
|
||||
searchBarContainer.set(.width, to: UIScreen.main.bounds.width - 32)
|
||||
searchBarContainer.addSubview(searchBar)
|
||||
navigationItem.titleView = searchBarContainer
|
||||
|
||||
|
@ -1676,6 +1662,11 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
|
|||
animated: (self.didFinishInitialLayout && isAnimated)
|
||||
)
|
||||
|
||||
// Need to explicitly call 'scrollViewDidScroll' here as it won't get triggered
|
||||
// by 'scrollToRow' if a scroll doesn't occur (eg. if there is less than 1 screen
|
||||
// of messages)
|
||||
self.scrollViewDidScroll(self.tableView)
|
||||
|
||||
// If we haven't finished the initial layout then we want to delay the highlight slightly
|
||||
// so it doesn't look buggy with the push transition
|
||||
if highlight {
|
||||
|
|
|
@ -199,8 +199,10 @@ private extension MentionSelectionView {
|
|||
displayNameLabel.text = profile.displayName(for: threadVariant)
|
||||
profilePictureView.update(
|
||||
publicKey: profile.id,
|
||||
threadVariant: .contact,
|
||||
customImageData: nil,
|
||||
profile: profile,
|
||||
threadVariant: threadVariant
|
||||
additionalProfile: nil
|
||||
)
|
||||
moderatorIconImageView.isHidden = !isUserModeratorOrAdmin
|
||||
separator.isHidden = isLast
|
||||
|
|
|
@ -46,7 +46,7 @@ final class DocumentView: UIView {
|
|||
// Size label
|
||||
let sizeLabel = UILabel()
|
||||
sizeLabel.font = .systemFont(ofSize: Values.verySmallFontSize)
|
||||
sizeLabel.text = OWSFormat.formatFileSize(UInt(attachment.byteCount))
|
||||
sizeLabel.text = Format.fileSize(attachment.byteCount)
|
||||
sizeLabel.themeTextColor = textColor
|
||||
sizeLabel.lineBreakMode = .byTruncatingTail
|
||||
|
||||
|
|
|
@ -111,11 +111,10 @@ public class MediaAlbumView: UIStackView {
|
|||
tintView.autoPinEdgesToSuperviewEdges()
|
||||
|
||||
let moreCount = max(1, items.count - MediaAlbumView.kMaxItems)
|
||||
let moreCountText = OWSFormat.formatInt(Int32(moreCount))
|
||||
let moreText = String(
|
||||
// Format for the 'more items' indicator for media galleries. Embeds {{the number of additional items}}.
|
||||
format: "MEDIA_GALLERY_MORE_ITEMS_FORMAT".localized(),
|
||||
moreCountText
|
||||
"\(moreCount)"
|
||||
)
|
||||
let moreLabel: UILabel = UILabel()
|
||||
moreLabel.font = .systemFont(ofSize: 24)
|
||||
|
|
|
@ -289,8 +289,10 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
|||
profilePictureView.isHidden = (!cellViewModel.shouldShowProfile || cellViewModel.profile == nil)
|
||||
profilePictureView.update(
|
||||
publicKey: cellViewModel.authorId,
|
||||
threadVariant: cellViewModel.threadVariant,
|
||||
customImageData: nil,
|
||||
profile: cellViewModel.profile,
|
||||
threadVariant: cellViewModel.threadVariant
|
||||
additionalProfile: nil
|
||||
)
|
||||
moderatorIconImageView.isHidden = (!cellViewModel.isSenderOpenGroupModerator || !cellViewModel.shouldShowProfile)
|
||||
|
||||
|
|
|
@ -85,10 +85,7 @@ class ThreadDisappearingMessagesViewModel: SessionTableViewModel<ThreadDisappear
|
|||
|
||||
override var title: String { "DISAPPEARING_MESSAGES".localized() }
|
||||
|
||||
private var _settingsData: [SectionModel] = []
|
||||
public override var settingsData: [SectionModel] { _settingsData }
|
||||
|
||||
public override var observableSettingsData: ObservableData { _observableSettingsData }
|
||||
public override var observableTableData: ObservableData { _observableTableData }
|
||||
|
||||
/// 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
|
||||
|
@ -97,7 +94,7 @@ class ThreadDisappearingMessagesViewModel: SessionTableViewModel<ThreadDisappear
|
|||
/// 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
|
||||
private lazy var _observableSettingsData: ObservableData = ValueObservation
|
||||
private lazy var _observableTableData: ObservableData = ValueObservation
|
||||
.trackingConstantRegion { [weak self, config] db -> [SectionModel] in
|
||||
return [
|
||||
SectionModel(
|
||||
|
@ -131,12 +128,9 @@ class ThreadDisappearingMessagesViewModel: SessionTableViewModel<ThreadDisappear
|
|||
}
|
||||
.removeDuplicates()
|
||||
.publisher(in: dependencies.storage, scheduling: dependencies.scheduler)
|
||||
.mapToSessionTableViewData(for: self)
|
||||
|
||||
// MARK: - Functions
|
||||
|
||||
public override func updateSettings(_ updatedSettings: [SectionModel]) {
|
||||
self._settingsData = updatedSettings
|
||||
}
|
||||
|
||||
private func saveChanges() {
|
||||
let threadId: String = self.threadId
|
||||
|
|
|
@ -29,7 +29,10 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
|||
}
|
||||
|
||||
public enum Setting: Differentiable {
|
||||
case threadInfo
|
||||
case avatar
|
||||
case nickname
|
||||
case sessionId
|
||||
|
||||
case copyThreadId
|
||||
case allMedia
|
||||
case searchConversation
|
||||
|
@ -170,10 +173,7 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
|||
}
|
||||
}
|
||||
|
||||
private var _settingsData: [SectionModel] = []
|
||||
public override var settingsData: [SectionModel] { _settingsData }
|
||||
|
||||
public override var observableSettingsData: ObservableData { _observableSettingsData }
|
||||
public override var observableTableData: ObservableData { _observableTableData }
|
||||
|
||||
/// 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
|
||||
|
@ -182,7 +182,7 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
|||
/// 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
|
||||
private lazy var _observableSettingsData: ObservableData = ValueObservation
|
||||
private lazy var _observableTableData: ObservableData = ValueObservation
|
||||
.trackingConstantRegion { [weak self, dependencies, threadId = self.threadId, threadVariant = self.threadVariant] db -> [SectionModel] in
|
||||
let userPublicKey: String = getUserHexEncodedPublicKey(db, dependencies: dependencies)
|
||||
let maybeThreadViewModel: SessionThreadViewModel? = try SessionThreadViewModel
|
||||
|
@ -207,25 +207,88 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
|||
threadVariant == .closedGroup &&
|
||||
threadViewModel.currentUserIsClosedGroupMember == true
|
||||
)
|
||||
let editIcon: UIImage? = UIImage(named: "icon_edit")
|
||||
|
||||
return [
|
||||
SectionModel(
|
||||
model: .conversationInfo,
|
||||
elements: [
|
||||
SessionCell.Info(
|
||||
id: .threadInfo,
|
||||
leftAccessory: .threadInfo(
|
||||
threadViewModel: threadViewModel,
|
||||
avatarTapped: { [weak self] in
|
||||
self?.updateProfilePicture(threadViewModel: threadViewModel)
|
||||
},
|
||||
titleTapped: { [weak self] in self?.setIsEditing(true) },
|
||||
titleChanged: { [weak self] text in self?.editedDisplayName = text }
|
||||
id: .avatar,
|
||||
accessory: .profile(
|
||||
id: threadViewModel.id,
|
||||
size: .extraLarge,
|
||||
threadVariant: threadVariant,
|
||||
customImageData: threadViewModel.openGroupProfilePictureData,
|
||||
profile: threadViewModel.profile,
|
||||
additionalProfile: threadViewModel.additionalProfile,
|
||||
cornerIcon: nil,
|
||||
accessibility: nil
|
||||
),
|
||||
title: threadViewModel.displayName,
|
||||
shouldHaveBackground: false
|
||||
styling: SessionCell.StyleInfo(
|
||||
alignment: .centerHugging,
|
||||
customPadding: SessionCell.Padding(bottom: Values.smallSpacing),
|
||||
backgroundStyle: .noBackground
|
||||
),
|
||||
onTap: { self?.viewProfilePicture(threadViewModel: threadViewModel) }
|
||||
),
|
||||
SessionCell.Info(
|
||||
id: .nickname,
|
||||
leftAccessory: (threadVariant != .contact ? nil :
|
||||
.icon(
|
||||
editIcon?.withRenderingMode(.alwaysTemplate),
|
||||
size: .fit,
|
||||
customTint: .textSecondary
|
||||
)
|
||||
),
|
||||
title: SessionCell.TextInfo(
|
||||
threadViewModel.displayName,
|
||||
font: .titleLarge,
|
||||
alignment: .center,
|
||||
editingPlaceholder: "CONTACT_NICKNAME_PLACEHOLDER".localized(),
|
||||
interaction: (threadVariant == .contact ? .editable : .none)
|
||||
),
|
||||
styling: SessionCell.StyleInfo(
|
||||
alignment: .centerHugging,
|
||||
customPadding: SessionCell.Padding(
|
||||
top: Values.smallSpacing,
|
||||
trailing: (threadVariant != .contact ?
|
||||
nil :
|
||||
-(((editIcon?.size.width ?? 0) + (Values.smallSpacing * 2)) / 2)
|
||||
),
|
||||
bottom: (threadVariant != .contact ?
|
||||
nil :
|
||||
Values.smallSpacing
|
||||
),
|
||||
interItem: 0
|
||||
),
|
||||
backgroundStyle: .noBackground
|
||||
),
|
||||
onTap: {
|
||||
self?.textChanged(self?.oldDisplayName, for: .nickname)
|
||||
self?.setIsEditing(true)
|
||||
}
|
||||
),
|
||||
|
||||
(threadVariant != .contact ? nil :
|
||||
SessionCell.Info(
|
||||
id: .sessionId,
|
||||
subtitle: SessionCell.TextInfo(
|
||||
threadViewModel.id,
|
||||
font: .monoSmall,
|
||||
alignment: .center,
|
||||
interaction: .copy
|
||||
),
|
||||
styling: SessionCell.StyleInfo(
|
||||
customPadding: SessionCell.Padding(
|
||||
top: Values.smallSpacing,
|
||||
bottom: Values.largeSpacing
|
||||
),
|
||||
backgroundStyle: .noBackground
|
||||
)
|
||||
)
|
||||
)
|
||||
]
|
||||
].compactMap { $0 }
|
||||
),
|
||||
SectionModel(
|
||||
model: .content,
|
||||
|
@ -241,27 +304,29 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
|||
"COPY_GROUP_URL".localized() :
|
||||
"vc_conversation_settings_copy_session_id_button_title".localized()
|
||||
),
|
||||
accessibilityIdentifier: "\(ThreadSettingsViewModel.self).copy_thread_id",
|
||||
accessibilityLabel: "Copy Session ID",
|
||||
accessibility: SessionCell.Accessibility(
|
||||
identifier: "\(ThreadSettingsViewModel.self).copy_thread_id",
|
||||
label: "Copy Session ID"
|
||||
),
|
||||
onTap: {
|
||||
switch threadVariant {
|
||||
case .contact, .closedGroup:
|
||||
UIPasteboard.general.string = threadId
|
||||
|
||||
|
||||
case .openGroup:
|
||||
guard
|
||||
let server: String = threadViewModel.openGroupServer,
|
||||
let roomToken: String = threadViewModel.openGroupRoomToken,
|
||||
let publicKey: String = threadViewModel.openGroupPublicKey
|
||||
else { return }
|
||||
|
||||
|
||||
UIPasteboard.general.string = OpenGroup.urlFor(
|
||||
server: server,
|
||||
roomToken: roomToken,
|
||||
publicKey: publicKey
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
self?.showToast(
|
||||
text: "copied".localized(),
|
||||
backgroundColor: .backgroundSecondary
|
||||
|
@ -269,7 +334,7 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
|||
}
|
||||
)
|
||||
),
|
||||
|
||||
|
||||
SessionCell.Info(
|
||||
id: .allMedia,
|
||||
leftAccessory: .icon(
|
||||
|
@ -277,8 +342,10 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
|||
.withRenderingMode(.alwaysTemplate)
|
||||
),
|
||||
title: MediaStrings.allMedia,
|
||||
accessibilityIdentifier: "\(ThreadSettingsViewModel.self).all_media",
|
||||
accessibilityLabel: "All media",
|
||||
accessibility: SessionCell.Accessibility(
|
||||
identifier: "\(ThreadSettingsViewModel.self).all_media",
|
||||
label: "All media"
|
||||
),
|
||||
onTap: { [weak self] in
|
||||
self?.transitionToScreen(
|
||||
MediaGalleryViewModel.createAllMediaViewController(
|
||||
|
@ -289,7 +356,7 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
|||
)
|
||||
}
|
||||
),
|
||||
|
||||
|
||||
SessionCell.Info(
|
||||
id: .searchConversation,
|
||||
leftAccessory: .icon(
|
||||
|
@ -297,13 +364,15 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
|||
.withRenderingMode(.alwaysTemplate)
|
||||
),
|
||||
title: "CONVERSATION_SETTINGS_SEARCH".localized(),
|
||||
accessibilityIdentifier: "\(ThreadSettingsViewModel.self).search",
|
||||
accessibilityLabel: "Search",
|
||||
accessibility: SessionCell.Accessibility(
|
||||
identifier: "\(ThreadSettingsViewModel.self).search",
|
||||
label: "Search"
|
||||
),
|
||||
onTap: { [weak self] in
|
||||
self?.didTriggerSearch()
|
||||
}
|
||||
),
|
||||
|
||||
|
||||
(threadVariant != .openGroup ? nil :
|
||||
SessionCell.Info(
|
||||
id: .addToOpenGroup,
|
||||
|
@ -312,7 +381,9 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
|||
.withRenderingMode(.alwaysTemplate)
|
||||
),
|
||||
title: "vc_conversation_settings_invite_button_title".localized(),
|
||||
accessibilityIdentifier: "\(ThreadSettingsViewModel.self).add_to_open_group",
|
||||
accessibility: SessionCell.Accessibility(
|
||||
identifier: "\(ThreadSettingsViewModel.self).add_to_open_group"
|
||||
),
|
||||
onTap: { [weak self] in
|
||||
self?.transitionToScreen(
|
||||
UserSelectionVC(
|
||||
|
@ -328,7 +399,7 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
|||
}
|
||||
)
|
||||
),
|
||||
|
||||
|
||||
(threadVariant == .openGroup || threadViewModel.threadIsBlocked == true ? nil :
|
||||
SessionCell.Info(
|
||||
id: .disappearingMessages,
|
||||
|
@ -338,7 +409,10 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
|||
"ic_timer" :
|
||||
"ic_timer_disabled"
|
||||
)
|
||||
)?.withRenderingMode(.alwaysTemplate)
|
||||
)?.withRenderingMode(.alwaysTemplate),
|
||||
accessibility: SessionCell.Accessibility(
|
||||
label: "Timer icon"
|
||||
)
|
||||
),
|
||||
title: "DISAPPEARING_MESSAGES".localized(),
|
||||
subtitle: (disappearingMessagesConfig.isEnabled ?
|
||||
|
@ -348,9 +422,10 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
|||
) :
|
||||
"DISAPPEARING_MESSAGES_SUBTITLE_OFF".localized()
|
||||
),
|
||||
accessibilityIdentifier: "\(ThreadSettingsViewModel.self).disappearing_messages",
|
||||
accessibilityLabel: "Disappearing messages",
|
||||
leftAccessoryAccessibilityLabel: "Timer icon",
|
||||
accessibility: SessionCell.Accessibility(
|
||||
identifier: "\(ThreadSettingsViewModel.self).disappearing_messages",
|
||||
label: "Disappearing messages"
|
||||
),
|
||||
onTap: { [weak self] in
|
||||
self?.transitionToScreen(
|
||||
SessionTableViewController(
|
||||
|
@ -363,7 +438,7 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
|||
}
|
||||
)
|
||||
),
|
||||
|
||||
|
||||
(!currentUserIsClosedGroupMember ? nil :
|
||||
SessionCell.Info(
|
||||
id: .editGroup,
|
||||
|
@ -372,8 +447,10 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
|||
.withRenderingMode(.alwaysTemplate)
|
||||
),
|
||||
title: "EDIT_GROUP_ACTION".localized(),
|
||||
accessibilityIdentifier: "Edit group",
|
||||
accessibilityLabel: "Edit group",
|
||||
accessibility: SessionCell.Accessibility(
|
||||
identifier: "Edit group",
|
||||
label: "Edit group"
|
||||
),
|
||||
onTap: { [weak self] in
|
||||
self?.transitionToScreen(EditClosedGroupVC(threadId: threadId))
|
||||
}
|
||||
|
@ -388,8 +465,10 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
|||
.withRenderingMode(.alwaysTemplate)
|
||||
),
|
||||
title: "LEAVE_GROUP_ACTION".localized(),
|
||||
accessibilityIdentifier: "Leave group",
|
||||
accessibilityLabel: "Leave group",
|
||||
accessibility: SessionCell.Accessibility(
|
||||
identifier: "Leave group",
|
||||
label: "Leave group"
|
||||
),
|
||||
confirmationInfo: ConfirmationModal.Info(
|
||||
title: "CONFIRM_LEAVE_GROUP_TITLE".localized(),
|
||||
explanation: (currentUserIsClosedGroupMember ?
|
||||
|
@ -401,9 +480,11 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
|||
cancelStyle: .alert_text
|
||||
),
|
||||
onTap: { [weak self] in
|
||||
dependencies.storage.writeAsync { db in
|
||||
try MessageSender.leave(db, groupPublicKey: threadId)
|
||||
}
|
||||
dependencies.storage
|
||||
.writePublisherFlatMap { db in
|
||||
MessageSender.leave(db, groupPublicKey: threadId)
|
||||
}
|
||||
.sinkUntilComplete()
|
||||
}
|
||||
)
|
||||
),
|
||||
|
@ -445,8 +526,10 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
|||
threadViewModel.threadVariant != .closedGroup ||
|
||||
currentUserIsClosedGroupMember
|
||||
),
|
||||
accessibilityIdentifier: "Mentions only notification setting",
|
||||
accessibilityLabel: "Mentions only",
|
||||
accessibility: SessionCell.Accessibility(
|
||||
identifier: "Mentions only notification setting",
|
||||
label: "Mentions only"
|
||||
),
|
||||
onTap: {
|
||||
let newValue: Bool = !(threadViewModel.threadOnlyNotifyForMentions == true)
|
||||
|
||||
|
@ -478,8 +561,10 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
|||
threadViewModel.threadVariant != .closedGroup ||
|
||||
currentUserIsClosedGroupMember
|
||||
),
|
||||
accessibilityIdentifier: "\(ThreadSettingsViewModel.self).mute",
|
||||
accessibilityLabel: "Mute notifications",
|
||||
accessibility: SessionCell.Accessibility(
|
||||
identifier: "\(ThreadSettingsViewModel.self).mute",
|
||||
label: "Mute notifications"
|
||||
),
|
||||
onTap: {
|
||||
dependencies.storage.writeAsync { db in
|
||||
let currentValue: TimeInterval? = try SessionThread
|
||||
|
@ -515,8 +600,10 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
|||
rightAccessory: .toggle(
|
||||
.boolValue(threadViewModel.threadIsBlocked == true)
|
||||
),
|
||||
accessibilityIdentifier: "\(ThreadSettingsViewModel.self).block",
|
||||
accessibilityLabel: "Block",
|
||||
accessibility: SessionCell.Accessibility(
|
||||
identifier: "\(ThreadSettingsViewModel.self).block",
|
||||
label: "Block"
|
||||
),
|
||||
confirmationInfo: ConfirmationModal.Info(
|
||||
title: {
|
||||
guard threadViewModel.threadIsBlocked == true else {
|
||||
|
@ -561,14 +648,11 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
|||
}
|
||||
.removeDuplicates()
|
||||
.publisher(in: dependencies.storage, scheduling: dependencies.scheduler)
|
||||
.mapToSessionTableViewData(for: self)
|
||||
|
||||
// MARK: - Functions
|
||||
|
||||
public override func updateSettings(_ updatedSettings: [SectionModel]) {
|
||||
self._settingsData = updatedSettings
|
||||
}
|
||||
|
||||
private func updateProfilePicture(threadViewModel: SessionThreadViewModel) {
|
||||
private func viewProfilePicture(threadViewModel: SessionThreadViewModel) {
|
||||
guard
|
||||
threadViewModel.threadVariant == .contact,
|
||||
let profile: Profile = threadViewModel.profile,
|
||||
|
|
|
@ -431,7 +431,8 @@ extension ReactionListSheet: UITableViewDelegate, UITableViewDataSource {
|
|||
cell.update(
|
||||
with: SessionCell.Info(
|
||||
id: cellViewModel,
|
||||
leftAccessory: .profile(authorId, cellViewModel.profile),
|
||||
position: Position.with(indexPath.row, count: self.selectedReactionUserList.count),
|
||||
leftAccessory: .profile(id: authorId, profile: cellViewModel.profile),
|
||||
title: (
|
||||
cellViewModel.profile?.displayName() ??
|
||||
Profile.truncated(
|
||||
|
@ -446,10 +447,9 @@ extension ReactionListSheet: UITableViewDelegate, UITableViewDataSource {
|
|||
size: .fit
|
||||
)
|
||||
),
|
||||
styling: SessionCell.StyleInfo(backgroundStyle: .edgeToEdge),
|
||||
isEnabled: (authorId == self.messageViewModel.currentUserPublicKey)
|
||||
),
|
||||
style: .edgeToEdge,
|
||||
position: Position.with(indexPath.row, count: self.selectedReactionUserList.count)
|
||||
)
|
||||
)
|
||||
|
||||
return cell
|
||||
|
|
|
@ -472,8 +472,10 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
|
|||
profilePictureView.size = profilePictureSize
|
||||
profilePictureView.update(
|
||||
publicKey: getUserHexEncodedPublicKey(),
|
||||
threadVariant: .contact,
|
||||
customImageData: nil,
|
||||
profile: Profile.fetchOrCreateCurrentUser(),
|
||||
threadVariant: .contact
|
||||
additionalProfile: nil
|
||||
)
|
||||
profilePictureView.set(.width, to: profilePictureSize)
|
||||
profilePictureView.set(.height, to: profilePictureSize)
|
||||
|
|
|
@ -142,13 +142,13 @@ final class NewConversationVC: BaseVC, ThemedNavigation, UITableViewDelegate, UI
|
|||
cell.update(
|
||||
with: SessionCell.Info(
|
||||
id: profile,
|
||||
leftAccessory: .profile(profile.id, profile),
|
||||
title: profile.displayName()
|
||||
),
|
||||
style: .edgeToEdge,
|
||||
position: Position.with(
|
||||
indexPath.row,
|
||||
count: newConversationViewModel.sectionData[indexPath.section].contacts.count
|
||||
position: Position.with(
|
||||
indexPath.row,
|
||||
count: newConversationViewModel.sectionData[indexPath.section].contacts.count
|
||||
),
|
||||
leftAccessory: .profile(id: profile.id, profile: profile),
|
||||
title: profile.displayName(),
|
||||
styling: SessionCell.StyleInfo(backgroundStyle: .edgeToEdge)
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
@ -497,7 +497,7 @@ class DocumentCell: UITableViewCell {
|
|||
func update(with item: MediaGalleryViewModel.Item) {
|
||||
let attachment = item.attachment
|
||||
titleLabel.text = (attachment.sourceFilename ?? "File")
|
||||
detailLabel.text = "\(OWSFormat.formatFileSize(UInt(attachment.byteCount)))"
|
||||
detailLabel.text = "\(Format.fileSize(attachment.byteCount)))"
|
||||
timeLabel.text = Date(
|
||||
timeIntervalSince1970: TimeInterval(item.interactionTimestampMs / 1000)
|
||||
).formattedForDisplay
|
||||
|
|
|
@ -34,12 +34,9 @@ class PhotoCollectionPickerViewModel: SessionTableViewModel<NoNav, PhotoCollecti
|
|||
// MARK: - Content
|
||||
|
||||
override var title: String { "NOTIFICATIONS_STYLE_SOUND_TITLE".localized() }
|
||||
override var observableTableData: ObservableData { _observableTableData }
|
||||
|
||||
private var _settingsData: [SectionModel] = []
|
||||
public override var settingsData: [SectionModel] { _settingsData }
|
||||
public override var observableSettingsData: ObservableData { _observableSettingsData }
|
||||
|
||||
private lazy var _observableSettingsData: ObservableData = {
|
||||
private lazy var _observableTableData: ObservableData = {
|
||||
self.photoCollections
|
||||
.map { collections in
|
||||
[
|
||||
|
@ -49,15 +46,15 @@ class PhotoCollectionPickerViewModel: SessionTableViewModel<NoNav, PhotoCollecti
|
|||
let contents: PhotoCollectionContents = collection.contents()
|
||||
let photoMediaSize: PhotoMediaSize = PhotoMediaSize(
|
||||
thumbnailSize: CGSize(
|
||||
width: IconSize.veryLarge.size,
|
||||
height: IconSize.veryLarge.size
|
||||
width: IconSize.extraLarge.size,
|
||||
height: IconSize.extraLarge.size
|
||||
)
|
||||
)
|
||||
let lastAssetItem: PhotoPickerAssetItem? = contents.lastAssetItem(photoMediaSize: photoMediaSize)
|
||||
|
||||
return SessionCell.Info(
|
||||
id: Item(id: collection.id),
|
||||
leftAccessory: .iconAsync(size: .veryLarge, shouldFill: true) { imageView in
|
||||
leftAccessory: .iconAsync(size: .extraLarge, shouldFill: true) { imageView in
|
||||
// Note: We need to capture 'lastAssetItem' otherwise it'll be released and we won't
|
||||
// be able to load the thumbnail
|
||||
lastAssetItem?.asyncThumbnail { [weak imageView] image in
|
||||
|
@ -76,14 +73,9 @@ class PhotoCollectionPickerViewModel: SessionTableViewModel<NoNav, PhotoCollecti
|
|||
}
|
||||
.removeDuplicates()
|
||||
.eraseToAnyPublisher()
|
||||
.mapToSessionTableViewData(for: self)
|
||||
}()
|
||||
|
||||
// MARK: - Functions
|
||||
|
||||
public override func updateSettings(_ updatedSettings: [SectionModel]) {
|
||||
self._settingsData = updatedSettings
|
||||
}
|
||||
|
||||
|
||||
// MARK: PhotoLibraryDelegate
|
||||
|
||||
func photoLibraryDidChange(_ photoLibrary: PhotoLibrary) {
|
||||
|
|
|
@ -20,7 +20,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
|||
private var loadingViewController: LoadingViewController?
|
||||
|
||||
/// This needs to be a lazy variable to ensure it doesn't get initialized before it actually needs to be used
|
||||
lazy var poller: Poller = Poller()
|
||||
lazy var poller: CurrentUserPoller = CurrentUserPoller()
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
|
@ -564,7 +564,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
|||
public func startPollersIfNeeded(shouldStartGroupPollers: Bool = true) {
|
||||
guard Identity.userExists() else { return }
|
||||
|
||||
poller.startIfNeeded()
|
||||
poller.start()
|
||||
|
||||
guard shouldStartGroupPollers else { return }
|
||||
|
||||
|
@ -574,7 +574,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
|||
|
||||
public func stopPollers(shouldStopUserPoller: Bool = true) {
|
||||
if shouldStopUserPoller {
|
||||
poller.stop()
|
||||
poller.stopAllPollers()
|
||||
}
|
||||
|
||||
ClosedGroupPoller.shared.stopAllPollers()
|
||||
|
|
|
@ -17,7 +17,6 @@
|
|||
#import <Reachability/Reachability.h>
|
||||
#import <SignalCoreKit/Cryptography.h>
|
||||
#import <SessionMessagingKit/OWSAudioPlayer.h>
|
||||
#import <SignalUtilitiesKit/OWSFormat.h>
|
||||
#import <SignalUtilitiesKit/OWSViewController.h>
|
||||
#import <SignalUtilitiesKit/UIFont+OWS.h>
|
||||
#import <SessionUtilitiesKit/UIView+OWS.h>
|
||||
|
|
|
@ -596,3 +596,5 @@
|
|||
"MESSAGE_STATE_READ" = "Read";
|
||||
"MESSAGE_STATE_SENT" = "Sent";
|
||||
"MESSAGE_REQUEST_PENDING_APPROVAL_INFO" = "You will be able to send voice messages and attachments once the recipient has approved this message request";
|
||||
"REMOVE_AVATAR" = "Remove";
|
||||
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
|
||||
|
|
|
@ -596,3 +596,5 @@
|
|||
"MESSAGE_STATE_READ" = "Read";
|
||||
"MESSAGE_STATE_SENT" = "Sent";
|
||||
"MESSAGE_REQUEST_PENDING_APPROVAL_INFO" = "You will be able to send voice messages and attachments once the recipient has approved this message request";
|
||||
"REMOVE_AVATAR" = "Remove";
|
||||
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
|
||||
|
|
|
@ -596,3 +596,5 @@
|
|||
"MESSAGE_STATE_READ" = "Read";
|
||||
"MESSAGE_STATE_SENT" = "Sent";
|
||||
"MESSAGE_REQUEST_PENDING_APPROVAL_INFO" = "You will be able to send voice messages and attachments once the recipient has approved this message request";
|
||||
"REMOVE_AVATAR" = "Remove";
|
||||
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
|
||||
|
|
|
@ -596,3 +596,5 @@
|
|||
"MESSAGE_STATE_READ" = "خوانده شد";
|
||||
"MESSAGE_STATE_SENT" = "ارسال شد";
|
||||
"MESSAGE_REQUEST_PENDING_APPROVAL_INFO" = "You will be able to send voice messages and attachments once the recipient has approved this message request";
|
||||
"REMOVE_AVATAR" = "Remove";
|
||||
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
|
||||
|
|
|
@ -596,3 +596,5 @@
|
|||
"MESSAGE_STATE_READ" = "Read";
|
||||
"MESSAGE_STATE_SENT" = "Sent";
|
||||
"MESSAGE_REQUEST_PENDING_APPROVAL_INFO" = "You will be able to send voice messages and attachments once the recipient has approved this message request";
|
||||
"REMOVE_AVATAR" = "Remove";
|
||||
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
|
||||
|
|
|
@ -596,3 +596,5 @@
|
|||
"MESSAGE_STATE_READ" = "Read";
|
||||
"MESSAGE_STATE_SENT" = "Sent";
|
||||
"MESSAGE_REQUEST_PENDING_APPROVAL_INFO" = "You will be able to send voice messages and attachments once the recipient has approved this message request";
|
||||
"REMOVE_AVATAR" = "Remove";
|
||||
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
|
||||
|
|
|
@ -596,3 +596,5 @@
|
|||
"MESSAGE_STATE_READ" = "Read";
|
||||
"MESSAGE_STATE_SENT" = "Sent";
|
||||
"MESSAGE_REQUEST_PENDING_APPROVAL_INFO" = "You will be able to send voice messages and attachments once the recipient has approved this message request";
|
||||
"REMOVE_AVATAR" = "Remove";
|
||||
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
|
||||
|
|
|
@ -596,3 +596,5 @@
|
|||
"MESSAGE_STATE_READ" = "Read";
|
||||
"MESSAGE_STATE_SENT" = "Sent";
|
||||
"MESSAGE_REQUEST_PENDING_APPROVAL_INFO" = "You will be able to send voice messages and attachments once the recipient has approved this message request";
|
||||
"REMOVE_AVATAR" = "Remove";
|
||||
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
|
||||
|
|
|
@ -596,3 +596,5 @@
|
|||
"MESSAGE_STATE_READ" = "Read";
|
||||
"MESSAGE_STATE_SENT" = "Sent";
|
||||
"MESSAGE_REQUEST_PENDING_APPROVAL_INFO" = "You will be able to send voice messages and attachments once the recipient has approved this message request";
|
||||
"REMOVE_AVATAR" = "Remove";
|
||||
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
|
||||
|
|
|
@ -596,3 +596,5 @@
|
|||
"MESSAGE_STATE_READ" = "Read";
|
||||
"MESSAGE_STATE_SENT" = "Sent";
|
||||
"MESSAGE_REQUEST_PENDING_APPROVAL_INFO" = "You will be able to send voice messages and attachments once the recipient has approved this message request";
|
||||
"REMOVE_AVATAR" = "Remove";
|
||||
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
|
||||
|
|
|
@ -596,3 +596,5 @@
|
|||
"MESSAGE_STATE_READ" = "Read";
|
||||
"MESSAGE_STATE_SENT" = "Sent";
|
||||
"MESSAGE_REQUEST_PENDING_APPROVAL_INFO" = "You will be able to send voice messages and attachments once the recipient has approved this message request";
|
||||
"REMOVE_AVATAR" = "Remove";
|
||||
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
|
||||
|
|
|
@ -596,3 +596,5 @@
|
|||
"MESSAGE_STATE_READ" = "Read";
|
||||
"MESSAGE_STATE_SENT" = "Sent";
|
||||
"MESSAGE_REQUEST_PENDING_APPROVAL_INFO" = "You will be able to send voice messages and attachments once the recipient has approved this message request";
|
||||
"REMOVE_AVATAR" = "Remove";
|
||||
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
|
||||
|
|
|
@ -596,3 +596,5 @@
|
|||
"MESSAGE_STATE_READ" = "Read";
|
||||
"MESSAGE_STATE_SENT" = "Sent";
|
||||
"MESSAGE_REQUEST_PENDING_APPROVAL_INFO" = "You will be able to send voice messages and attachments once the recipient has approved this message request";
|
||||
"REMOVE_AVATAR" = "Remove";
|
||||
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
|
||||
|
|
|
@ -596,3 +596,5 @@
|
|||
"MESSAGE_STATE_READ" = "Read";
|
||||
"MESSAGE_STATE_SENT" = "Sent";
|
||||
"MESSAGE_REQUEST_PENDING_APPROVAL_INFO" = "You will be able to send voice messages and attachments once the recipient has approved this message request";
|
||||
"REMOVE_AVATAR" = "Remove";
|
||||
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
|
||||
|
|
|
@ -596,3 +596,5 @@
|
|||
"MESSAGE_STATE_READ" = "Read";
|
||||
"MESSAGE_STATE_SENT" = "Sent";
|
||||
"MESSAGE_REQUEST_PENDING_APPROVAL_INFO" = "You will be able to send voice messages and attachments once the recipient has approved this message request";
|
||||
"REMOVE_AVATAR" = "Remove";
|
||||
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
|
||||
|
|
|
@ -596,3 +596,5 @@
|
|||
"MESSAGE_STATE_READ" = "Read";
|
||||
"MESSAGE_STATE_SENT" = "Sent";
|
||||
"MESSAGE_REQUEST_PENDING_APPROVAL_INFO" = "You will be able to send voice messages and attachments once the recipient has approved this message request";
|
||||
"REMOVE_AVATAR" = "Remove";
|
||||
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
|
||||
|
|
|
@ -596,3 +596,5 @@
|
|||
"MESSAGE_STATE_READ" = "Read";
|
||||
"MESSAGE_STATE_SENT" = "Sent";
|
||||
"MESSAGE_REQUEST_PENDING_APPROVAL_INFO" = "You will be able to send voice messages and attachments once the recipient has approved this message request";
|
||||
"REMOVE_AVATAR" = "Remove";
|
||||
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
|
||||
|
|
|
@ -596,3 +596,5 @@
|
|||
"MESSAGE_STATE_READ" = "Read";
|
||||
"MESSAGE_STATE_SENT" = "Sent";
|
||||
"MESSAGE_REQUEST_PENDING_APPROVAL_INFO" = "You will be able to send voice messages and attachments once the recipient has approved this message request";
|
||||
"REMOVE_AVATAR" = "Remove";
|
||||
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
|
||||
|
|
|
@ -596,3 +596,5 @@
|
|||
"MESSAGE_STATE_READ" = "Read";
|
||||
"MESSAGE_STATE_SENT" = "Sent";
|
||||
"MESSAGE_REQUEST_PENDING_APPROVAL_INFO" = "You will be able to send voice messages and attachments once the recipient has approved this message request";
|
||||
"REMOVE_AVATAR" = "Remove";
|
||||
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
|
||||
|
|
|
@ -596,3 +596,5 @@
|
|||
"MESSAGE_STATE_READ" = "Read";
|
||||
"MESSAGE_STATE_SENT" = "Sent";
|
||||
"MESSAGE_REQUEST_PENDING_APPROVAL_INFO" = "You will be able to send voice messages and attachments once the recipient has approved this message request";
|
||||
"REMOVE_AVATAR" = "Remove";
|
||||
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
|
||||
|
|
|
@ -596,3 +596,5 @@
|
|||
"MESSAGE_STATE_READ" = "Read";
|
||||
"MESSAGE_STATE_SENT" = "Sent";
|
||||
"MESSAGE_REQUEST_PENDING_APPROVAL_INFO" = "You will be able to send voice messages and attachments once the recipient has approved this message request";
|
||||
"REMOVE_AVATAR" = "Remove";
|
||||
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
|
||||
|
|
|
@ -596,3 +596,5 @@
|
|||
"MESSAGE_STATE_READ" = "Read";
|
||||
"MESSAGE_STATE_SENT" = "Sent";
|
||||
"MESSAGE_REQUEST_PENDING_APPROVAL_INFO" = "You will be able to send voice messages and attachments once the recipient has approved this message request";
|
||||
"REMOVE_AVATAR" = "Remove";
|
||||
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
|
||||
|
|
|
@ -538,12 +538,7 @@ class NotificationActionHandler {
|
|||
variant: .standardOutgoing,
|
||||
body: replyText,
|
||||
timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000)),
|
||||
hasMention: Interaction.isUserMentioned(
|
||||
db,
|
||||
threadId: threadId,
|
||||
threadVariant: thread.variant,
|
||||
body: replyText
|
||||
),
|
||||
hasMention: Interaction.isUserMentioned(db, threadId: threadId, body: replyText),
|
||||
expiresInSeconds: try? DisappearingMessagesConfiguration
|
||||
.select(.durationSeconds)
|
||||
.filter(id: threadId)
|
||||
|
|
|
@ -1,515 +0,0 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import GRDB
|
||||
import DifferenceKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
import SignalUtilitiesKit
|
||||
|
||||
class BlockedContactsViewController: BaseVC, UITableViewDelegate, UITableViewDataSource {
|
||||
private static let loadingHeaderHeight: CGFloat = 40
|
||||
|
||||
private let viewModel: BlockedContactsViewModel = BlockedContactsViewModel()
|
||||
private var dataChangeObservable: DatabaseCancellable?
|
||||
private var hasLoadedInitialContactData: Bool = false
|
||||
private var isLoadingMore: Bool = false
|
||||
private var isAutoLoadingNextPage: Bool = false
|
||||
private var viewHasAppeared: Bool = false
|
||||
|
||||
// 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 tableView: UITableView = {
|
||||
let result: UITableView = UITableView()
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.clipsToBounds = true
|
||||
result.separatorStyle = .none
|
||||
result.themeBackgroundColor = .clear
|
||||
result.showsVerticalScrollIndicator = false
|
||||
result.register(view: SessionCell.self)
|
||||
result.dataSource = self
|
||||
result.delegate = self
|
||||
result.layer.cornerRadius = SessionCell.cornerRadius
|
||||
|
||||
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 = "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_EMPTY_STATE".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 unblockButton: SessionButton = {
|
||||
let result: SessionButton = SessionButton(style: .destructive, size: .large)
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.setTitle("CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK".localized(), for: .normal)
|
||||
result.addTarget(self, action: #selector(unblockTapped), for: .touchUpInside)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
view.themeBackgroundColor = .backgroundPrimary
|
||||
|
||||
ViewControllerUtilities.setUpDefaultSessionStyle(
|
||||
for: self,
|
||||
title: "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_TITLE".localized(),
|
||||
hasCustomBackButton: false
|
||||
)
|
||||
|
||||
view.addSubview(tableView)
|
||||
view.addSubview(emptyStateLabel)
|
||||
view.addSubview(fadeView)
|
||||
view.addSubview(unblockButton)
|
||||
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)
|
||||
|
||||
// Stop observing database changes
|
||||
dataChangeObservable?.cancel()
|
||||
}
|
||||
|
||||
@objc func applicationDidBecomeActive(_ notification: Notification) {
|
||||
startObservingChanges(didReturnFromBackground: true)
|
||||
}
|
||||
|
||||
@objc func applicationDidResignActive(_ notification: Notification) {
|
||||
// Stop observing database changes
|
||||
dataChangeObservable?.cancel()
|
||||
}
|
||||
|
||||
// MARK: - Layout
|
||||
|
||||
private func setupLayout() {
|
||||
NSLayoutConstraint.activate([
|
||||
tableView.topAnchor.constraint(equalTo: view.topAnchor, constant: Values.smallSpacing),
|
||||
tableView.leftAnchor.constraint(equalTo: view.leftAnchor, constant: Values.largeSpacing),
|
||||
tableView.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -Values.largeSpacing),
|
||||
tableView.bottomAnchor.constraint(
|
||||
equalTo: unblockButton.topAnchor,
|
||||
constant: -Values.largeSpacing
|
||||
),
|
||||
|
||||
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),
|
||||
|
||||
unblockButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||
unblockButton.bottomAnchor.constraint(
|
||||
equalTo: view.safeAreaLayoutGuide.bottomAnchor,
|
||||
constant: -Values.smallSpacing
|
||||
),
|
||||
unblockButton.widthAnchor.constraint(equalToConstant: Values.iPadButtonWidth)
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: - Updating
|
||||
|
||||
private func startObservingChanges(didReturnFromBackground: Bool = false) {
|
||||
self.viewModel.onContactChange = { [weak self] updatedContactData, changeset in
|
||||
self?.handleContactUpdates(updatedContactData, 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 handleContactUpdates(
|
||||
_ updatedData: [BlockedContactsViewModel.SectionModel],
|
||||
changeset: StagedChangeset<[BlockedContactsViewModel.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 hasLoadedInitialContactData else {
|
||||
hasLoadedInitialContactData = true
|
||||
UIView.performWithoutAnimation {
|
||||
handleContactUpdates(updatedData, changeset: changeset, initialLoad: true)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Show the empty state if there is no data
|
||||
let hasContactsData: Bool = (updatedData
|
||||
.first(where: { $0.model == .contacts })?
|
||||
.elements
|
||||
.isEmpty == false)
|
||||
unblockButton.isEnabled = !viewModel.selectedContactIds.isEmpty
|
||||
unblockButton.isHidden = !hasContactsData
|
||||
emptyStateLabel.isHidden = hasContactsData
|
||||
|
||||
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.updateContactData(updatedData)
|
||||
}
|
||||
|
||||
CATransaction.commit()
|
||||
}
|
||||
|
||||
private func autoLoadNextPageIfNeeded() {
|
||||
guard !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: [(BlockedContactsViewModel.Section, CGRect)] = (self?.viewModel.contactData
|
||||
.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.contactData.count
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
let section: BlockedContactsViewModel.SectionModel = viewModel.contactData[section]
|
||||
|
||||
return section.elements.count
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let section: BlockedContactsViewModel.SectionModel = viewModel.contactData[indexPath.section]
|
||||
|
||||
switch section.model {
|
||||
case .contacts:
|
||||
let info: SessionCell.Info<Profile> = section.elements[indexPath.row]
|
||||
let cell: SessionCell = tableView.dequeue(type: SessionCell.self, for: indexPath)
|
||||
cell.update(
|
||||
with: info,
|
||||
style: .roundedEdgeToEdge,
|
||||
position: Position.with(indexPath.row, count: section.elements.count)
|
||||
)
|
||||
|
||||
return cell
|
||||
|
||||
default: preconditionFailure("Other sections should have no content")
|
||||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
|
||||
let section: BlockedContactsViewModel.SectionModel = viewModel.contactData[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, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||
return UITableView.automaticDimension
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||
return UITableView.automaticDimension
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
|
||||
let section: BlockedContactsViewModel.SectionModel = viewModel.contactData[section]
|
||||
|
||||
switch section.model {
|
||||
case .loadMore: return BlockedContactsViewController.loadingHeaderHeight
|
||||
default: return 0
|
||||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, willDisplayHeaderView view: UIView, forSection section: Int) {
|
||||
guard self.hasLoadedInitialContactData && self.viewHasAppeared && !self.isLoadingMore else { return }
|
||||
|
||||
let section: BlockedContactsViewModel.SectionModel = self.viewModel.contactData[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: BlockedContactsViewModel.SectionModel = self.viewModel.contactData[indexPath.section]
|
||||
|
||||
switch section.model {
|
||||
case .contacts:
|
||||
let info: SessionCell.Info<Profile> = section.elements[indexPath.row]
|
||||
|
||||
// Do nothing if the item is disabled
|
||||
guard info.isEnabled else { return }
|
||||
|
||||
// Get the view that was tapped (for presenting on iPad)
|
||||
let tappedView: UIView? = tableView.cellForRow(at: indexPath)
|
||||
let maybeOldSelection: (Int, SessionCell.Info<Profile>)? = section.elements
|
||||
.enumerated()
|
||||
.first(where: { index, info in
|
||||
switch (info.leftAccessory, info.rightAccessory) {
|
||||
case (_, .radio(_, let isSelected, _)): return isSelected()
|
||||
case (.radio(_, let isSelected, _), _): return isSelected()
|
||||
default: return false
|
||||
}
|
||||
})
|
||||
|
||||
info.onTap?(tappedView)
|
||||
self.manuallyReload(indexPath: indexPath, section: section, info: info)
|
||||
self.unblockButton.isEnabled = !self.viewModel.selectedContactIds.isEmpty
|
||||
|
||||
// Update the old selection as well
|
||||
if let oldSelection: (index: Int, info: SessionCell.Info<Profile>) = maybeOldSelection {
|
||||
self.manuallyReload(
|
||||
indexPath: IndexPath(
|
||||
row: oldSelection.index,
|
||||
section: indexPath.section
|
||||
),
|
||||
section: section,
|
||||
info: oldSelection.info
|
||||
)
|
||||
}
|
||||
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
private func manuallyReload(
|
||||
indexPath: IndexPath,
|
||||
section: BlockedContactsViewModel.SectionModel,
|
||||
info: SessionCell.Info<Profile>
|
||||
) {
|
||||
// Try update the existing cell to have a nice animation instead of reloading the cell
|
||||
if let existingCell: SessionCell = tableView.cellForRow(at: indexPath) as? SessionCell {
|
||||
existingCell.update(
|
||||
with: info,
|
||||
style: .roundedEdgeToEdge,
|
||||
position: Position.with(indexPath.row, count: section.elements.count)
|
||||
)
|
||||
}
|
||||
else {
|
||||
tableView.reloadRows(at: [indexPath], with: .none)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Interaction
|
||||
|
||||
@objc private func unblockTapped() {
|
||||
guard !viewModel.selectedContactIds.isEmpty else { return }
|
||||
|
||||
let contactIds: Set<String> = viewModel.selectedContactIds
|
||||
let contactNames: [String] = contactIds
|
||||
.map { contactId in
|
||||
guard
|
||||
let section: BlockedContactsViewModel.SectionModel = self.viewModel.contactData
|
||||
.first(where: { section in section.model == .contacts }),
|
||||
let info: SessionCell.Info<Profile> = section.elements
|
||||
.first(where: { info in info.id.id == contactId })
|
||||
else { return contactId }
|
||||
|
||||
return info.title
|
||||
}
|
||||
let confirmationTitle: String = {
|
||||
guard contactNames.count > 1 else {
|
||||
// Show a single users name
|
||||
return String(
|
||||
format: "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK_CONFIRMATION_TITLE_SINGLE".localized(),
|
||||
(
|
||||
contactNames.first ??
|
||||
"CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK_CONFIRMATION_TITLE_FALLBACK".localized()
|
||||
)
|
||||
)
|
||||
}
|
||||
guard contactNames.count > 3 else {
|
||||
// Show up to three users names
|
||||
let initialNames: [String] = Array(contactNames.prefix(upTo: (contactNames.count - 1)))
|
||||
let lastName: String = contactNames[contactNames.count - 1]
|
||||
|
||||
return [
|
||||
String(
|
||||
format: "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK_CONFIRMATION_TITLE_MULTIPLE_1".localized(),
|
||||
initialNames.joined(separator: ", ")
|
||||
),
|
||||
String(
|
||||
format: "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK_CONFIRMATION_TITLE_MULTIPLE_2_SINGLE".localized(),
|
||||
lastName
|
||||
)
|
||||
]
|
||||
.reversed(if: CurrentAppContext().isRTL)
|
||||
.joined(separator: " ")
|
||||
}
|
||||
|
||||
// If we have exactly 4 users, show the first two names followed by 'and X others', for
|
||||
// more than 4 users, show the first 3 names followed by 'and X others'
|
||||
let numNamesToShow: Int = (contactNames.count == 4 ? 2 : 3)
|
||||
let initialNames: [String] = Array(contactNames.prefix(upTo: numNamesToShow))
|
||||
|
||||
return [
|
||||
String(
|
||||
format: "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK_CONFIRMATION_TITLE_MULTIPLE_1".localized(),
|
||||
initialNames.joined(separator: ", ")
|
||||
),
|
||||
String(
|
||||
format: "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK_CONFIRMATION_TITLE_MULTIPLE_3".localized(),
|
||||
(contactNames.count - numNamesToShow)
|
||||
)
|
||||
]
|
||||
.reversed(if: CurrentAppContext().isRTL)
|
||||
.joined(separator: " ")
|
||||
}()
|
||||
let confirmationModal: ConfirmationModal = ConfirmationModal(
|
||||
info: ConfirmationModal.Info(
|
||||
title: confirmationTitle,
|
||||
confirmTitle: "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK_CONFIRMATION_ACTON".localized(),
|
||||
confirmStyle: .danger,
|
||||
cancelStyle: .alert_text
|
||||
) { _ in
|
||||
// Unblock the contacts
|
||||
Storage.shared.write { db in
|
||||
_ = try Contact
|
||||
.filter(ids: contactIds)
|
||||
.updateAll(db, Contact.Columns.isBlocked.set(to: false))
|
||||
|
||||
// Force a config sync
|
||||
try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete()
|
||||
}
|
||||
}
|
||||
)
|
||||
self.present(confirmationModal, animated: true, completion: nil)
|
||||
}
|
||||
}
|
|
@ -1,18 +1,25 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import GRDB
|
||||
import DifferenceKit
|
||||
import SessionUIKit
|
||||
import SignalUtilitiesKit
|
||||
|
||||
public class BlockedContactsViewModel {
|
||||
public typealias SectionModel = ArraySection<Section, SessionCell.Info<Profile>>
|
||||
|
||||
class BlockedContactsViewModel: SessionTableViewModel<NoNav, BlockedContactsViewModel.Section, Profile> {
|
||||
// MARK: - Section
|
||||
|
||||
public enum Section: Differentiable {
|
||||
public enum Section: SessionTableSection {
|
||||
case contacts
|
||||
case loadMore
|
||||
|
||||
var style: SessionTableSectionStyle {
|
||||
switch self {
|
||||
case .contacts: return .none
|
||||
case .loadMore: return .loadMore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Variables
|
||||
|
@ -21,14 +28,16 @@ public class BlockedContactsViewModel {
|
|||
|
||||
// MARK: - Initialization
|
||||
|
||||
init() {
|
||||
self.pagedDataObserver = nil
|
||||
override init() {
|
||||
_pagedDataObserver = nil
|
||||
|
||||
super.init()
|
||||
|
||||
// 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)
|
||||
self.pagedDataObserver = PagedDatabaseObserver(
|
||||
_pagedDataObserver = PagedDatabaseObserver(
|
||||
pagedTable: Profile.self,
|
||||
pageSize: BlockedContactsViewModel.pageSize,
|
||||
idColumn: .id,
|
||||
|
@ -63,12 +72,13 @@ public class BlockedContactsViewModel {
|
|||
),
|
||||
onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in
|
||||
PagedData.processAndTriggerUpdates(
|
||||
updatedData: self?.process(data: updatedData, for: updatedPageInfo),
|
||||
currentDataRetriever: { self?.contactData },
|
||||
onDataChange: self?.onContactChange,
|
||||
onUnobservedDataChange: { updatedData, changeset in
|
||||
self?.unobservedContactDataChanges = (updatedData, changeset)
|
||||
}
|
||||
updatedData: self?.process(data: updatedData, for: updatedPageInfo)
|
||||
.mapToSessionTableViewData(for: self),
|
||||
currentDataRetriever: { self?.tableData },
|
||||
onDataChange: { updatedData, changeset in
|
||||
self?.contactDataSubject.send((updatedData, changeset))
|
||||
},
|
||||
onUnobservedDataChange: { _, _ in }
|
||||
)
|
||||
}
|
||||
)
|
||||
|
@ -76,59 +86,80 @@ public class BlockedContactsViewModel {
|
|||
// Run the initial query on a background thread so we don't block the push transition
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||
// The `.pageBefore` will query from a `0` offset loading the first page
|
||||
self?.pagedDataObserver?.load(.pageBefore)
|
||||
self?._pagedDataObserver?.load(.pageBefore)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Contact Data
|
||||
|
||||
public private(set) var selectedContactIds: Set<String> = []
|
||||
public private(set) var unobservedContactDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>)?
|
||||
public private(set) var contactData: [SectionModel] = []
|
||||
public private(set) var pagedDataObserver: PagedDatabaseObserver<Profile, DataModel>?
|
||||
|
||||
public var onContactChange: (([SectionModel], StagedChangeset<[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 unobservedContactDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>) = self.unobservedContactDataChanges {
|
||||
onContactChange?(unobservedContactDataChanges.0 , unobservedContactDataChanges.1)
|
||||
self.unobservedContactDataChanges = nil
|
||||
}
|
||||
}
|
||||
override var title: String { "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_TITLE".localized() }
|
||||
override var emptyStateTextPublisher: AnyPublisher<String?, Never> {
|
||||
Just("CONVERSATION_SETTINGS_BLOCKED_CONTACTS_EMPTY_STATE".localized())
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
private func process(data: [DataModel], for pageInfo: PagedData.PageInfo) -> [SectionModel] {
|
||||
// Update the 'selectedContactIds' to only include selected contacts which are within the
|
||||
// data (ie. handle profile deletions)
|
||||
let profileIds: Set<String> = data.map { $0.id }.asSet()
|
||||
selectedContactIds = selectedContactIds.intersection(profileIds)
|
||||
|
||||
private let contactDataSubject: CurrentValueSubject<([SectionModel], StagedChangeset<[SectionModel]>), Never> = CurrentValueSubject(([], StagedChangeset()))
|
||||
private let selectedContactIdsSubject: CurrentValueSubject<Set<String>, Never> = CurrentValueSubject([])
|
||||
private var _pagedDataObserver: PagedDatabaseObserver<Profile, DataModel>?
|
||||
public override var pagedDataObserver: TransactionObserver? { _pagedDataObserver }
|
||||
|
||||
public override var observableTableData: ObservableData { _observableTableData }
|
||||
|
||||
private lazy var _observableTableData: ObservableData = contactDataSubject
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
|
||||
override var footerButtonInfo: AnyPublisher<SessionButton.Info?, Never> {
|
||||
selectedContactIdsSubject
|
||||
.prepend([])
|
||||
.map { selectedContactIds in
|
||||
SessionButton.Info(
|
||||
style: .destructive,
|
||||
title: "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK".localized(),
|
||||
isEnabled: !selectedContactIds.isEmpty,
|
||||
onTap: { [weak self] in self?.unblockTapped() }
|
||||
)
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
// MARK: - Functions
|
||||
|
||||
override func loadPageAfter() { _pagedDataObserver?.load(.pageAfter) }
|
||||
|
||||
private func process(
|
||||
data: [DataModel],
|
||||
for pageInfo: PagedData.PageInfo
|
||||
) -> [SectionModel] {
|
||||
return [
|
||||
[
|
||||
SectionModel(
|
||||
section: .contacts,
|
||||
elements: data
|
||||
.sorted { lhs, rhs -> Bool in
|
||||
lhs.profile.displayName() > rhs.profile.displayName()
|
||||
lhs.profile.displayName() < rhs.profile.displayName()
|
||||
}
|
||||
.map { model -> SessionCell.Info<Profile> in
|
||||
.map { [weak self] model -> SessionCell.Info<Profile> in
|
||||
SessionCell.Info(
|
||||
id: model.profile,
|
||||
leftAccessory: .profile(model.profile.id, model.profile),
|
||||
leftAccessory: .profile(id: model.profile.id, profile: model.profile),
|
||||
title: model.profile.displayName(),
|
||||
rightAccessory: .radio(
|
||||
isSelected: { [weak self] in
|
||||
self?.selectedContactIds.contains(model.profile.id) == true
|
||||
isSelected: {
|
||||
self?.selectedContactIdsSubject.value.contains(model.profile.id) == true
|
||||
}
|
||||
),
|
||||
onTap: { [weak self] in
|
||||
guard self?.selectedContactIds.contains(model.profile.id) == true else {
|
||||
self?.selectedContactIds.insert(model.profile.id)
|
||||
return
|
||||
onTap: {
|
||||
var updatedSelectedIds: Set<String> = (self?.selectedContactIdsSubject.value ?? [])
|
||||
|
||||
if !updatedSelectedIds.contains(model.profile.id) {
|
||||
updatedSelectedIds.insert(model.profile.id)
|
||||
}
|
||||
else {
|
||||
updatedSelectedIds.remove(model.profile.id)
|
||||
}
|
||||
|
||||
self?.selectedContactIds.remove(model.profile.id)
|
||||
self?.selectedContactIdsSubject.send(updatedSelectedIds)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
@ -210,7 +241,7 @@ public class BlockedContactsViewModel {
|
|||
confirmTitle: "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK_CONFIRMATION_ACTON".localized(),
|
||||
confirmStyle: .danger,
|
||||
cancelStyle: .alert_text
|
||||
) { _ in
|
||||
) { [weak self] _ in
|
||||
// Unblock the contacts
|
||||
Storage.shared.write { db in
|
||||
_ = try Contact
|
||||
|
@ -220,6 +251,8 @@ public class BlockedContactsViewModel {
|
|||
// Force a config sync
|
||||
try MessageSender.syncConfiguration(db, forceSyncNow: true).sinkUntilComplete()
|
||||
}
|
||||
|
||||
self?.selectedContactIdsSubject.send([])
|
||||
}
|
||||
)
|
||||
self.transitionToScreen(confirmationModal, transitionType: .present)
|
||||
|
@ -242,8 +275,8 @@ public class BlockedContactsViewModel {
|
|||
static func query(
|
||||
filterSQL: SQL,
|
||||
orderSQL: SQL
|
||||
) -> (([Int64]) -> AdaptedFetchRequest<SQLRequest<DataModel>>) {
|
||||
return { rowIds -> AdaptedFetchRequest<SQLRequest<DataModel>> in
|
||||
) -> (([Int64]) -> any FetchRequest<DataModel>) {
|
||||
return { rowIds -> any FetchRequest<DataModel> in
|
||||
let profile: TypedTableAlias<Profile> = TypedTableAlias()
|
||||
|
||||
/// **Note:** The `numColumnsBeforeProfile` value **MUST** match the number of fields before
|
||||
|
|
|
@ -26,7 +26,7 @@ class ConversationSettingsViewModel: SessionTableViewModel<NoNav, ConversationSe
|
|||
var style: SessionTableSectionStyle {
|
||||
switch self {
|
||||
case .blockedContacts: return .padding
|
||||
default: return .title
|
||||
default: return .titleRoundedContent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -35,10 +35,7 @@ class ConversationSettingsViewModel: SessionTableViewModel<NoNav, ConversationSe
|
|||
|
||||
override var title: String { "CONVERSATION_SETTINGS_TITLE".localized() }
|
||||
|
||||
private var _settingsData: [SectionModel] = []
|
||||
public override var settingsData: [SectionModel] { _settingsData }
|
||||
|
||||
public override var observableSettingsData: ObservableData { _observableSettingsData }
|
||||
public override var observableTableData: ObservableData { _observableTableData }
|
||||
|
||||
/// 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
|
||||
|
@ -47,7 +44,7 @@ class ConversationSettingsViewModel: SessionTableViewModel<NoNav, ConversationSe
|
|||
/// 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
|
||||
private lazy var _observableSettingsData: ObservableData = ValueObservation
|
||||
private lazy var _observableTableData: ObservableData = ValueObservation
|
||||
.trackingConstantRegion { db -> [SectionModel] in
|
||||
return [
|
||||
SectionModel(
|
||||
|
@ -92,10 +89,14 @@ class ConversationSettingsViewModel: SessionTableViewModel<NoNav, ConversationSe
|
|||
SessionCell.Info(
|
||||
id: .blockedContacts,
|
||||
title: "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_TITLE".localized(),
|
||||
tintColor: .danger,
|
||||
shouldHaveBackground: false,
|
||||
styling: SessionCell.StyleInfo(
|
||||
tintColor: .danger,
|
||||
backgroundStyle: .noBackground
|
||||
),
|
||||
onTap: { [weak self] in
|
||||
self?.transitionToScreen(BlockedContactsViewController())
|
||||
self?.transitionToScreen(
|
||||
SessionTableViewController(viewModel: BlockedContactsViewModel())
|
||||
)
|
||||
}
|
||||
)
|
||||
]
|
||||
|
@ -104,10 +105,5 @@ class ConversationSettingsViewModel: SessionTableViewModel<NoNav, ConversationSe
|
|||
}
|
||||
.removeDuplicates()
|
||||
.publisher(in: Storage.shared)
|
||||
|
||||
// MARK: - Functions
|
||||
|
||||
public override func updateSettings(_ updatedSettings: [SectionModel]) {
|
||||
self._settingsData = updatedSettings
|
||||
}
|
||||
.mapToSessionTableViewData(for: self)
|
||||
}
|
||||
|
|
|
@ -25,10 +25,7 @@ class HelpViewModel: SessionTableViewModel<NoNav, HelpViewModel.Section, HelpVie
|
|||
|
||||
override var title: String { "HELP_TITLE".localized() }
|
||||
|
||||
private var _settingsData: [SectionModel] = []
|
||||
public override var settingsData: [SectionModel] { _settingsData }
|
||||
|
||||
public override var observableSettingsData: ObservableData { _observableSettingsData }
|
||||
public override var observableTableData: ObservableData { _observableTableData }
|
||||
|
||||
/// 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
|
||||
|
@ -37,7 +34,7 @@ class HelpViewModel: SessionTableViewModel<NoNav, HelpViewModel.Section, HelpVie
|
|||
/// 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
|
||||
private lazy var _observableSettingsData: ObservableData = ValueObservation
|
||||
private lazy var _observableTableData: ObservableData = ValueObservation
|
||||
.trackingConstantRegion { db -> [SectionModel] in
|
||||
return [
|
||||
SectionModel(
|
||||
|
@ -50,7 +47,7 @@ class HelpViewModel: SessionTableViewModel<NoNav, HelpViewModel.Section, HelpVie
|
|||
rightAccessory: .highlightingBackgroundLabel(
|
||||
title: "HELP_REPORT_BUG_ACTION_TITLE".localized()
|
||||
),
|
||||
onTap: { HelpViewModel.shareLogs(targetView: $0) }
|
||||
onTapView: { HelpViewModel.shareLogs(targetView: $0) }
|
||||
)
|
||||
]
|
||||
),
|
||||
|
@ -142,12 +139,9 @@ class HelpViewModel: SessionTableViewModel<NoNav, HelpViewModel.Section, HelpVie
|
|||
}
|
||||
.removeDuplicates()
|
||||
.publisher(in: Storage.shared)
|
||||
.mapToSessionTableViewData(for: self)
|
||||
|
||||
// MARK: - Functions
|
||||
|
||||
public override func updateSettings(_ updatedSettings: [SectionModel]) {
|
||||
self._settingsData = updatedSettings
|
||||
}
|
||||
|
||||
public static func shareLogs(
|
||||
viewControllerToDismiss: UIViewController? = nil,
|
||||
|
|
|
@ -0,0 +1,57 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
|
||||
class ImagePickerHandler: NSObject, UIImagePickerControllerDelegate & UINavigationControllerDelegate {
|
||||
private let onTransition: (UIViewController, TransitionType) -> Void
|
||||
private let onImagePicked: (UIImage?, String?) -> Void
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(
|
||||
onTransition: @escaping (UIViewController, TransitionType) -> Void,
|
||||
onImagePicked: @escaping (UIImage?, String?) -> Void
|
||||
) {
|
||||
self.onTransition = onTransition
|
||||
self.onImagePicked = onImagePicked
|
||||
}
|
||||
|
||||
// MARK: - UIImagePickerControllerDelegate
|
||||
|
||||
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
|
||||
picker.dismiss(animated: true)
|
||||
}
|
||||
|
||||
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
|
||||
guard
|
||||
let imageUrl: URL = info[.imageURL] as? URL,
|
||||
let rawAvatar: UIImage = info[.originalImage] as? UIImage
|
||||
else {
|
||||
picker.presentingViewController?.dismiss(animated: true)
|
||||
return
|
||||
}
|
||||
|
||||
picker.presentingViewController?.dismiss(animated: true) { [weak self] in
|
||||
// Check if the user selected an animated image (if so then don't crop, just
|
||||
// set the avatar directly
|
||||
guard
|
||||
let type: Any = try? imageUrl.resourceValues(forKeys: [.typeIdentifierKey])
|
||||
.allValues
|
||||
.first,
|
||||
let typeString: String = type as? String,
|
||||
MIMETypeUtil.supportedAnimatedImageUTITypes().contains(typeString)
|
||||
else {
|
||||
let viewController: CropScaleImageViewController = CropScaleImageViewController(
|
||||
srcImage: rawAvatar,
|
||||
successCompletion: { resultImage in
|
||||
self?.onImagePicked(resultImage, nil)
|
||||
}
|
||||
)
|
||||
self?.onTransition(viewController, .present)
|
||||
return
|
||||
}
|
||||
|
||||
self?.onImagePicked(nil, imageUrl.path)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -31,10 +31,7 @@ class NotificationContentViewModel: SessionTableViewModel<NoNav, NotificationSet
|
|||
|
||||
override var title: String { "NOTIFICATIONS_STYLE_CONTENT_TITLE".localized() }
|
||||
|
||||
private var _settingsData: [SectionModel] = []
|
||||
public override var settingsData: [SectionModel] { _settingsData }
|
||||
|
||||
public override var observableSettingsData: ObservableData { _observableSettingsData }
|
||||
public override var observableTableData: ObservableData { _observableTableData }
|
||||
|
||||
/// 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
|
||||
|
@ -43,7 +40,7 @@ class NotificationContentViewModel: SessionTableViewModel<NoNav, NotificationSet
|
|||
/// 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
|
||||
private lazy var _observableSettingsData: ObservableData = ValueObservation
|
||||
private lazy var _observableTableData: ObservableData = ValueObservation
|
||||
.trackingConstantRegion { [storage] db -> [SectionModel] in
|
||||
let currentSelection: Preferences.NotificationPreviewType? = db[.preferencesNotificationPreviewType]
|
||||
.defaulting(to: .defaultPreviewType)
|
||||
|
@ -73,10 +70,5 @@ class NotificationContentViewModel: SessionTableViewModel<NoNav, NotificationSet
|
|||
}
|
||||
.removeDuplicates()
|
||||
.publisher(in: storage, scheduling: scheduler)
|
||||
|
||||
// MARK: - Functions
|
||||
|
||||
public override func updateSettings(_ updatedSettings: [SectionModel]) {
|
||||
self._settingsData = updatedSettings
|
||||
}
|
||||
.mapToSessionTableViewData(for: self)
|
||||
}
|
||||
|
|
|
@ -26,13 +26,14 @@ class NotificationSettingsViewModel: SessionTableViewModel<NoNav, NotificationSe
|
|||
var style: SessionTableSectionStyle {
|
||||
switch self {
|
||||
case .content: return .padding
|
||||
default: return .title
|
||||
default: return .titleRoundedContent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum Setting: Differentiable {
|
||||
case strategyUseFastMode
|
||||
case strategyDeviceSettings
|
||||
case styleSound
|
||||
case styleSoundWhenAppIsOpen
|
||||
case content
|
||||
|
@ -42,10 +43,7 @@ class NotificationSettingsViewModel: SessionTableViewModel<NoNav, NotificationSe
|
|||
|
||||
override var title: String { "NOTIFICATIONS_TITLE".localized() }
|
||||
|
||||
private var _settingsData: [SectionModel] = []
|
||||
public override var settingsData: [SectionModel] { _settingsData }
|
||||
|
||||
public override var observableSettingsData: ObservableData { _observableSettingsData }
|
||||
public override var observableTableData: ObservableData { _observableTableData }
|
||||
|
||||
/// 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
|
||||
|
@ -54,7 +52,7 @@ class NotificationSettingsViewModel: SessionTableViewModel<NoNav, NotificationSe
|
|||
/// 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
|
||||
private lazy var _observableSettingsData: ObservableData = ValueObservation
|
||||
private lazy var _observableTableData: ObservableData = ValueObservation
|
||||
.trackingConstantRegion { db -> [SectionModel] in
|
||||
let notificationSound: Preferences.Sound = db[.defaultNotificationSound]
|
||||
.defaulting(to: Preferences.Sound.defaultNotificationSound)
|
||||
|
@ -72,9 +70,9 @@ class NotificationSettingsViewModel: SessionTableViewModel<NoNav, NotificationSe
|
|||
rightAccessory: .toggle(
|
||||
.userDefaults(UserDefaults.standard, key: "isUsingFullAPNs")
|
||||
),
|
||||
extraAction: SessionCell.ExtraAction(
|
||||
title: "NOTIFICATIONS_STRATEGY_FAST_MODE_ACTION".localized(),
|
||||
onTap: { UIApplication.shared.openSystemSettings() }
|
||||
styling: SessionCell.StyleInfo(
|
||||
allowedSeparators: [.top],
|
||||
customPadding: SessionCell.Padding(bottom: Values.verySmallSpacing)
|
||||
),
|
||||
onTap: {
|
||||
UserDefaults.standard.set(
|
||||
|
@ -85,6 +83,19 @@ class NotificationSettingsViewModel: SessionTableViewModel<NoNav, NotificationSe
|
|||
// Force sync the push tokens on change
|
||||
SyncPushTokensJob.run(uploadOnlyIfStale: false)
|
||||
}
|
||||
),
|
||||
SessionCell.Info(
|
||||
id: .strategyDeviceSettings,
|
||||
title: SessionCell.TextInfo(
|
||||
"NOTIFICATIONS_STRATEGY_FAST_MODE_ACTION".localized(),
|
||||
font: .subtitleBold
|
||||
),
|
||||
styling: SessionCell.StyleInfo(
|
||||
tintColor: .settings_tertiaryAction,
|
||||
allowedSeparators: [.bottom],
|
||||
customPadding: SessionCell.Padding(top: Values.verySmallSpacing)
|
||||
),
|
||||
onTap: { UIApplication.shared.openSystemSettings() }
|
||||
)
|
||||
]
|
||||
),
|
||||
|
@ -137,10 +148,5 @@ class NotificationSettingsViewModel: SessionTableViewModel<NoNav, NotificationSe
|
|||
}
|
||||
.removeDuplicates()
|
||||
.publisher(in: Storage.shared)
|
||||
|
||||
// MARK: - Functions
|
||||
|
||||
public override func updateSettings(_ updatedSettings: [SectionModel]) {
|
||||
self._settingsData = updatedSettings
|
||||
}
|
||||
.mapToSessionTableViewData(for: self)
|
||||
}
|
||||
|
|
|
@ -76,10 +76,7 @@ class NotificationSoundViewModel: SessionTableViewModel<NotificationSoundViewMod
|
|||
|
||||
override var title: String { "NOTIFICATIONS_STYLE_SOUND_TITLE".localized() }
|
||||
|
||||
private var _settingsData: [SectionModel] = []
|
||||
public override var settingsData: [SectionModel] { _settingsData }
|
||||
|
||||
public override var observableSettingsData: ObservableData { _observableSettingsData }
|
||||
public override var observableTableData: ObservableData { _observableTableData }
|
||||
|
||||
/// 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
|
||||
|
@ -88,7 +85,7 @@ class NotificationSoundViewModel: SessionTableViewModel<NotificationSoundViewMod
|
|||
/// 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
|
||||
private lazy var _observableSettingsData: ObservableData = ValueObservation
|
||||
private lazy var _observableTableData: ObservableData = ValueObservation
|
||||
.trackingConstantRegion { [weak self] db -> [SectionModel] in
|
||||
self?.storedSelection = try {
|
||||
guard let threadId: String = self?.threadId else {
|
||||
|
@ -150,12 +147,9 @@ class NotificationSoundViewModel: SessionTableViewModel<NotificationSoundViewMod
|
|||
}
|
||||
.removeDuplicates()
|
||||
.publisher(in: Storage.shared)
|
||||
.mapToSessionTableViewData(for: self)
|
||||
|
||||
// MARK: - Functions
|
||||
|
||||
public override func updateSettings(_ updatedSettings: [SectionModel]) {
|
||||
self._settingsData = updatedSettings
|
||||
}
|
||||
|
||||
private func saveChanges() {
|
||||
guard let currentSelection: Preferences.Sound = self.currentSelection.value else { return }
|
||||
|
|
|
@ -42,7 +42,7 @@ class PrivacySettingsViewModel: SessionTableViewModel<PrivacySettingsViewModel.N
|
|||
}
|
||||
}
|
||||
|
||||
var style: SessionTableSectionStyle { return .title }
|
||||
var style: SessionTableSectionStyle { return .titleRoundedContent }
|
||||
}
|
||||
|
||||
public enum Item: Differentiable {
|
||||
|
@ -76,10 +76,7 @@ class PrivacySettingsViewModel: SessionTableViewModel<PrivacySettingsViewModel.N
|
|||
|
||||
override var title: String { "PRIVACY_TITLE".localized() }
|
||||
|
||||
private var _settingsData: [SectionModel] = []
|
||||
public override var settingsData: [SectionModel] { _settingsData }
|
||||
|
||||
public override var observableSettingsData: ObservableData { _observableSettingsData }
|
||||
public override var observableTableData: ObservableData { _observableTableData }
|
||||
|
||||
/// 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
|
||||
|
@ -88,7 +85,7 @@ class PrivacySettingsViewModel: SessionTableViewModel<PrivacySettingsViewModel.N
|
|||
/// 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
|
||||
private lazy var _observableSettingsData: ObservableData = ValueObservation
|
||||
private lazy var _observableTableData: ObservableData = ValueObservation
|
||||
.trackingConstantRegion { db -> [SectionModel] in
|
||||
return [
|
||||
SectionModel(
|
||||
|
@ -128,34 +125,40 @@ class PrivacySettingsViewModel: SessionTableViewModel<PrivacySettingsViewModel.N
|
|||
elements: [
|
||||
SessionCell.Info(
|
||||
id: .typingIndicators,
|
||||
title: "PRIVACY_TYPING_INDICATORS_TITLE".localized(),
|
||||
subtitle: "PRIVACY_TYPING_INDICATORS_DESCRIPTION".localized(),
|
||||
subtitleExtraViewGenerator: {
|
||||
let targetHeight: CGFloat = 20
|
||||
let targetWidth: CGFloat = ceil(20 * (targetHeight / 12))
|
||||
let result: UIView = UIView(
|
||||
frame: CGRect(x: 0, y: 0, width: targetWidth, height: targetHeight)
|
||||
)
|
||||
result.set(.width, to: targetWidth)
|
||||
result.set(.height, to: targetHeight)
|
||||
|
||||
// Use a transform scale to reduce the size of the typing indicator to the
|
||||
// desired size (this way the animation remains intact)
|
||||
let cell: TypingIndicatorCell = TypingIndicatorCell()
|
||||
cell.transform = CGAffineTransform.scale(targetHeight / cell.bounds.height)
|
||||
cell.typingIndicatorView.startAnimation()
|
||||
result.addSubview(cell)
|
||||
|
||||
// Note: Because we are messing with the transform these values don't work
|
||||
// logically so we inset the positioning to make it look visually centered
|
||||
// within the layout inspector
|
||||
cell.center(.vertical, in: result, withInset: -(targetHeight * 0.15))
|
||||
cell.center(.horizontal, in: result, withInset: -(targetWidth * 0.35))
|
||||
cell.set(.width, to: .width, of: result)
|
||||
cell.set(.height, to: .height, of: result)
|
||||
|
||||
return result
|
||||
},
|
||||
title: SessionCell.TextInfo(
|
||||
"PRIVACY_TYPING_INDICATORS_TITLE".localized(),
|
||||
font: .title
|
||||
),
|
||||
subtitle: SessionCell.TextInfo(
|
||||
"PRIVACY_TYPING_INDICATORS_DESCRIPTION".localized(),
|
||||
font: .subtitle,
|
||||
extraViewGenerator: {
|
||||
let targetHeight: CGFloat = 20
|
||||
let targetWidth: CGFloat = ceil(20 * (targetHeight / 12))
|
||||
let result: UIView = UIView(
|
||||
frame: CGRect(x: 0, y: 0, width: targetWidth, height: targetHeight)
|
||||
)
|
||||
result.set(.width, to: targetWidth)
|
||||
result.set(.height, to: targetHeight)
|
||||
|
||||
// Use a transform scale to reduce the size of the typing indicator to the
|
||||
// desired size (this way the animation remains intact)
|
||||
let cell: TypingIndicatorCell = TypingIndicatorCell()
|
||||
cell.transform = CGAffineTransform.scale(targetHeight / cell.bounds.height)
|
||||
cell.typingIndicatorView.startAnimation()
|
||||
result.addSubview(cell)
|
||||
|
||||
// Note: Because we are messing with the transform these values don't work
|
||||
// logically so we inset the positioning to make it look visually centered
|
||||
// within the layout inspector
|
||||
cell.center(.vertical, in: result, withInset: -(targetHeight * 0.15))
|
||||
cell.center(.horizontal, in: result, withInset: -(targetWidth * 0.35))
|
||||
cell.set(.width, to: .width, of: result)
|
||||
cell.set(.height, to: .height, of: result)
|
||||
|
||||
return result
|
||||
}
|
||||
),
|
||||
rightAccessory: .toggle(.settingBool(key: .typingIndicatorsEnabled)),
|
||||
onTap: {
|
||||
Storage.shared.write { db in
|
||||
|
@ -189,7 +192,9 @@ class PrivacySettingsViewModel: SessionTableViewModel<PrivacySettingsViewModel.N
|
|||
title: "PRIVACY_CALLS_TITLE".localized(),
|
||||
subtitle: "PRIVACY_CALLS_DESCRIPTION".localized(),
|
||||
rightAccessory: .toggle(.settingBool(key: .areCallsEnabled)),
|
||||
accessibilityLabel: "Allow voice and video calls",
|
||||
accessibility: SessionCell.Accessibility(
|
||||
label: "Allow voice and video calls"
|
||||
),
|
||||
confirmationInfo: ConfirmationModal.Info(
|
||||
title: "PRIVACY_CALLS_WARNING_TITLE".localized(),
|
||||
explanation: "PRIVACY_CALLS_WARNING_DESCRIPTION".localized(),
|
||||
|
@ -211,10 +216,5 @@ class PrivacySettingsViewModel: SessionTableViewModel<PrivacySettingsViewModel.N
|
|||
}
|
||||
.removeDuplicates()
|
||||
.publisher(in: Storage.shared)
|
||||
|
||||
// MARK: - Functions
|
||||
|
||||
public override func updateSettings(_ updatedSettings: [SectionModel]) {
|
||||
self._settingsData = updatedSettings
|
||||
}
|
||||
.mapToSessionTableViewData(for: self)
|
||||
}
|
||||
|
|
|
@ -26,12 +26,33 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
|
|||
|
||||
public enum Section: SessionTableSection {
|
||||
case profileInfo
|
||||
case sessionId
|
||||
case menus
|
||||
case footer
|
||||
|
||||
var title: String? {
|
||||
switch self {
|
||||
case .sessionId: return "your_session_id".localized()
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
var style: SessionTableSectionStyle {
|
||||
switch self {
|
||||
case .sessionId: return .titleSeparator
|
||||
case .menus: return .padding
|
||||
default: return .none
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum Item: Differentiable {
|
||||
case profileInfo
|
||||
case avatar
|
||||
case profileName
|
||||
|
||||
case sessionId
|
||||
case idActions
|
||||
|
||||
case path
|
||||
case privacy
|
||||
case notifications
|
||||
|
@ -47,7 +68,18 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
|
|||
// MARK: - Variables
|
||||
|
||||
private let userSessionId: String
|
||||
private lazy var imagePickerHandler: ImagePickerHandler = ImagePickerHandler(viewModel: self)
|
||||
private lazy var imagePickerHandler: ImagePickerHandler = ImagePickerHandler(
|
||||
onTransition: { [weak self] in self?.transitionToScreen($0, transitionType: $1) },
|
||||
onImagePicked: { [weak self] resultImage, resultImagePath in
|
||||
self?.updateProfile(
|
||||
name: (self?.oldDisplayName ?? ""),
|
||||
profilePicture: resultImage,
|
||||
profilePictureFilePath: resultImagePath,
|
||||
isUpdatingDisplayName: false,
|
||||
isUpdatingProfilePicture: true
|
||||
)
|
||||
}
|
||||
)
|
||||
fileprivate var oldDisplayName: String
|
||||
private var editedDisplayName: String?
|
||||
|
||||
|
@ -63,8 +95,20 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
|
|||
// MARK: - Navigation
|
||||
|
||||
lazy var navState: AnyPublisher<NavState, Never> = {
|
||||
isEditing
|
||||
.map { isEditing in (isEditing ? .editing : .standard) }
|
||||
Publishers
|
||||
.CombineLatest(
|
||||
isEditing
|
||||
.map { isEditing in isEditing },
|
||||
textChanged
|
||||
.handleEvents(
|
||||
receiveOutput: { [weak self] value, _ in
|
||||
self?.editedDisplayName = value
|
||||
}
|
||||
)
|
||||
.filter { _ in false }
|
||||
.prepend((nil, .profileName))
|
||||
)
|
||||
.map { isEditing, _ -> NavState in (isEditing ? .editing : .standard) }
|
||||
.removeDuplicates()
|
||||
.prepend(.standard) // Initial value
|
||||
.eraseToAnyPublisher()
|
||||
|
@ -176,10 +220,7 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
|
|||
|
||||
override var title: String { "vc_settings_title".localized() }
|
||||
|
||||
private var _settingsData: [SectionModel] = []
|
||||
public override var settingsData: [SectionModel] { _settingsData }
|
||||
|
||||
public override var observableSettingsData: ObservableData { _observableSettingsData }
|
||||
public override var observableTableData: ObservableData { _observableTableData }
|
||||
|
||||
/// 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
|
||||
|
@ -188,8 +229,8 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
|
|||
/// 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
|
||||
private lazy var _observableSettingsData: ObservableData = ValueObservation
|
||||
.trackingConstantRegion { db -> [SectionModel] in
|
||||
private lazy var _observableTableData: ObservableData = ValueObservation
|
||||
.trackingConstantRegion { [weak self] db -> [SectionModel] in
|
||||
let userPublicKey: String = getUserHexEncodedPublicKey(db)
|
||||
let profile: Profile = Profile.fetchOrCreateCurrentUser(db)
|
||||
|
||||
|
@ -198,38 +239,82 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
|
|||
model: .profileInfo,
|
||||
elements: [
|
||||
SessionCell.Info(
|
||||
id: .profileInfo,
|
||||
leftAccessory: .threadInfo(
|
||||
threadViewModel: SessionThreadViewModel(
|
||||
threadId: profile.id,
|
||||
threadIsNoteToSelf: true,
|
||||
contactProfile: profile
|
||||
),
|
||||
style: SessionCell.Accessory.ThreadInfoStyle(
|
||||
separatorTitle: "your_session_id".localized(),
|
||||
descriptionStyle: .monoLarge,
|
||||
descriptionActions: [
|
||||
SessionCell.Accessory.ThreadInfoStyle.Action(
|
||||
title: "copy".localized(),
|
||||
run: { [weak self] button in
|
||||
self?.copySessionId(profile.id, button: button)
|
||||
}
|
||||
|
||||
),
|
||||
SessionCell.Accessory.ThreadInfoStyle.Action(
|
||||
title: "share".localized(),
|
||||
run: { [weak self] _ in
|
||||
self?.shareSessionId(profile.id)
|
||||
}
|
||||
)
|
||||
]
|
||||
),
|
||||
avatarTapped: { [weak self] in self?.updateProfilePicture() },
|
||||
titleTapped: { [weak self] in self?.setIsEditing(true) },
|
||||
titleChanged: { [weak self] text in self?.editedDisplayName = text }
|
||||
id: .avatar,
|
||||
accessory: .profile(
|
||||
id: profile.id,
|
||||
size: .extraLarge,
|
||||
profile: profile
|
||||
),
|
||||
title: profile.displayName(),
|
||||
shouldHaveBackground: false
|
||||
styling: SessionCell.StyleInfo(
|
||||
alignment: .centerHugging,
|
||||
customPadding: SessionCell.Padding(bottom: Values.smallSpacing),
|
||||
backgroundStyle: .noBackground
|
||||
),
|
||||
onTap: {
|
||||
self?.updateProfilePicture(
|
||||
hasCustomImage: ProfileManager.hasProfileImageData(
|
||||
with: profile.profilePictureFileName
|
||||
)
|
||||
)
|
||||
}
|
||||
),
|
||||
SessionCell.Info(
|
||||
id: .profileName,
|
||||
title: SessionCell.TextInfo(
|
||||
profile.displayName(),
|
||||
font: .titleLarge,
|
||||
alignment: .center,
|
||||
interaction: .editable
|
||||
),
|
||||
styling: SessionCell.StyleInfo(
|
||||
alignment: .centerHugging,
|
||||
customPadding: SessionCell.Padding(top: Values.smallSpacing),
|
||||
backgroundStyle: .noBackground
|
||||
),
|
||||
onTap: { self?.setIsEditing(true) }
|
||||
)
|
||||
]
|
||||
),
|
||||
SectionModel(
|
||||
model: .sessionId,
|
||||
elements: [
|
||||
SessionCell.Info(
|
||||
id: .sessionId,
|
||||
title: SessionCell.TextInfo(
|
||||
profile.id,
|
||||
font: .monoLarge,
|
||||
alignment: .center,
|
||||
interaction: .copy
|
||||
),
|
||||
styling: SessionCell.StyleInfo(
|
||||
customPadding: SessionCell.Padding(bottom: Values.smallSpacing),
|
||||
backgroundStyle: .noBackground
|
||||
)
|
||||
),
|
||||
SessionCell.Info(
|
||||
id: .idActions,
|
||||
leftAccessory: .button(
|
||||
style: .bordered,
|
||||
title: "copy".localized(),
|
||||
run: { button in
|
||||
self?.copySessionId(profile.id, button: button)
|
||||
}
|
||||
),
|
||||
rightAccessory: .button(
|
||||
style: .bordered,
|
||||
title: "share".localized(),
|
||||
run: { _ in
|
||||
self?.shareSessionId(profile.id)
|
||||
}
|
||||
),
|
||||
styling: SessionCell.StyleInfo(
|
||||
customPadding: SessionCell.Padding(
|
||||
top: Values.smallSpacing,
|
||||
leading: 0,
|
||||
trailing: 0
|
||||
),
|
||||
backgroundStyle: .noBackground
|
||||
)
|
||||
)
|
||||
]
|
||||
),
|
||||
|
@ -238,7 +323,7 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
|
|||
elements: [
|
||||
SessionCell.Info(
|
||||
id: .path,
|
||||
leftAccessory: .customView {
|
||||
leftAccessory: .customView(hashValue: "PathStatusView") {
|
||||
// Need to ensure this view is the same size as the icons so
|
||||
// wrap it in a larger view
|
||||
let result: UIView = UIView()
|
||||
|
@ -252,7 +337,7 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
|
|||
return result
|
||||
},
|
||||
title: "vc_path_title".localized(),
|
||||
onTap: { [weak self] in self?.transitionToScreen(PathVC()) }
|
||||
onTap: { self?.transitionToScreen(PathVC()) }
|
||||
),
|
||||
SessionCell.Info(
|
||||
id: .privacy,
|
||||
|
@ -261,7 +346,7 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
|
|||
.withRenderingMode(.alwaysTemplate)
|
||||
),
|
||||
title: "vc_settings_privacy_button_title".localized(),
|
||||
onTap: { [weak self] in
|
||||
onTap: {
|
||||
self?.transitionToScreen(
|
||||
SessionTableViewController(viewModel: PrivacySettingsViewModel())
|
||||
)
|
||||
|
@ -274,7 +359,7 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
|
|||
.withRenderingMode(.alwaysTemplate)
|
||||
),
|
||||
title: "vc_settings_notifications_button_title".localized(),
|
||||
onTap: { [weak self] in
|
||||
onTap: {
|
||||
self?.transitionToScreen(
|
||||
SessionTableViewController(viewModel: NotificationSettingsViewModel())
|
||||
)
|
||||
|
@ -287,7 +372,7 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
|
|||
.withRenderingMode(.alwaysTemplate)
|
||||
),
|
||||
title: "CONVERSATION_SETTINGS_TITLE".localized(),
|
||||
onTap: { [weak self] in
|
||||
onTap: {
|
||||
self?.transitionToScreen(
|
||||
SessionTableViewController(viewModel: ConversationSettingsViewModel())
|
||||
)
|
||||
|
@ -300,7 +385,7 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
|
|||
.withRenderingMode(.alwaysTemplate)
|
||||
),
|
||||
title: "MESSAGE_REQUESTS_TITLE".localized(),
|
||||
onTap: { [weak self] in
|
||||
onTap: {
|
||||
self?.transitionToScreen(MessageRequestsViewController())
|
||||
}
|
||||
),
|
||||
|
@ -311,7 +396,7 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
|
|||
.withRenderingMode(.alwaysTemplate)
|
||||
),
|
||||
title: "APPEARANCE_TITLE".localized(),
|
||||
onTap: { [weak self] in
|
||||
onTap: {
|
||||
self?.transitionToScreen(AppearanceViewController())
|
||||
}
|
||||
),
|
||||
|
@ -322,7 +407,7 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
|
|||
.withRenderingMode(.alwaysTemplate)
|
||||
),
|
||||
title: "vc_settings_invite_a_friend_button_title".localized(),
|
||||
onTap: { [weak self] in
|
||||
onTap: {
|
||||
let invitation: String = "Hey, I've been using Session to chat with complete privacy and security. Come join me! Download it at https://getsession.org/. My Session ID is \(profile.id) !"
|
||||
|
||||
self?.transitionToScreen(
|
||||
|
@ -341,7 +426,7 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
|
|||
.withRenderingMode(.alwaysTemplate)
|
||||
),
|
||||
title: "vc_settings_recovery_phrase_button_title".localized(),
|
||||
onTap: { [weak self] in
|
||||
onTap: {
|
||||
self?.transitionToScreen(SeedModal(), transitionType: .present)
|
||||
}
|
||||
),
|
||||
|
@ -352,7 +437,7 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
|
|||
.withRenderingMode(.alwaysTemplate)
|
||||
),
|
||||
title: "HELP_TITLE".localized(),
|
||||
onTap: { [weak self] in
|
||||
onTap: {
|
||||
self?.transitionToScreen(
|
||||
SessionTableViewController(viewModel: HelpViewModel())
|
||||
)
|
||||
|
@ -365,8 +450,8 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
|
|||
.withRenderingMode(.alwaysTemplate)
|
||||
),
|
||||
title: "vc_settings_clear_all_data_button_title".localized(),
|
||||
tintColor: .danger,
|
||||
onTap: { [weak self] in
|
||||
styling: SessionCell.StyleInfo(tintColor: .danger),
|
||||
onTap: {
|
||||
self?.transitionToScreen(NukeDataModal(), transitionType: .present)
|
||||
}
|
||||
)
|
||||
|
@ -376,6 +461,7 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
|
|||
}
|
||||
.removeDuplicates()
|
||||
.publisher(in: Storage.shared)
|
||||
.mapToSessionTableViewData(for: self)
|
||||
|
||||
public override var footerView: AnyPublisher<UIView?, Never> {
|
||||
Just(VersionFooterView())
|
||||
|
@ -383,26 +469,30 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
|
|||
}
|
||||
|
||||
// MARK: - Functions
|
||||
|
||||
public override func updateSettings(_ updatedSettings: [SectionModel]) {
|
||||
self._settingsData = updatedSettings
|
||||
}
|
||||
|
||||
private func updateProfilePicture() {
|
||||
private func updateProfilePicture(hasCustomImage: Bool) {
|
||||
let actionSheet: UIAlertController = UIAlertController(
|
||||
title: "Update Profile Picture",
|
||||
message: nil,
|
||||
preferredStyle: .actionSheet
|
||||
)
|
||||
let action = UIAlertAction(
|
||||
actionSheet.addAction(UIAlertAction(
|
||||
title: "MEDIA_FROM_LIBRARY_BUTTON".localized(),
|
||||
style: .default,
|
||||
handler: { [weak self] _ in
|
||||
self?.showPhotoLibraryForAvatar()
|
||||
}
|
||||
)
|
||||
action.accessibilityLabel = "Photo library"
|
||||
actionSheet.addAction(action)
|
||||
))
|
||||
|
||||
// Only have the 'remove' button if there is a custom avatar set
|
||||
if hasCustomImage {
|
||||
actionSheet.addAction(UIAlertAction(
|
||||
title: "REMOVE_AVATAR".localized(),
|
||||
style: .destructive,
|
||||
handler: { [weak self] _ in self?.removeProfileImage() }
|
||||
))
|
||||
}
|
||||
|
||||
actionSheet.addAction(UIAlertAction(title: "cancel".localized(), style: .cancel, handler: nil))
|
||||
|
||||
self.transitionToScreen(actionSheet, transitionType: .present)
|
||||
|
|
|
@ -81,8 +81,10 @@ class BlockedContactCell: UITableViewCell {
|
|||
public func update(with cellViewModel: BlockedContactsViewModel.DataModel, isSelected: Bool) {
|
||||
profilePictureView.update(
|
||||
publicKey: cellViewModel.profile.id,
|
||||
threadVariant: .contact,
|
||||
customImageData: nil,
|
||||
profile: cellViewModel.profile,
|
||||
threadVariant: .contact
|
||||
additionalProfile: nil
|
||||
)
|
||||
selectionView.text = cellViewModel.profile.displayName()
|
||||
selectionView.update(isSelected: isSelected)
|
||||
|
|
|
@ -232,11 +232,10 @@ public final class FullConversationCell: UITableViewCell {
|
|||
public func updateForMessageSearchResult(with cellViewModel: SessionThreadViewModel, searchText: String) {
|
||||
profilePictureView.update(
|
||||
publicKey: cellViewModel.threadId,
|
||||
profile: cellViewModel.profile,
|
||||
additionalProfile: cellViewModel.additionalProfile,
|
||||
threadVariant: cellViewModel.threadVariant,
|
||||
openGroupProfilePictureData: cellViewModel.openGroupProfilePictureData,
|
||||
useFallbackPicture: (cellViewModel.threadVariant == .openGroup && cellViewModel.openGroupProfilePictureData == nil)
|
||||
customImageData: cellViewModel.openGroupProfilePictureData,
|
||||
profile: cellViewModel.profile,
|
||||
additionalProfile: cellViewModel.additionalProfile
|
||||
)
|
||||
|
||||
isPinnedIcon.isHidden = true
|
||||
|
@ -283,11 +282,10 @@ public final class FullConversationCell: UITableViewCell {
|
|||
public func updateForContactAndGroupSearchResult(with cellViewModel: SessionThreadViewModel, searchText: String) {
|
||||
profilePictureView.update(
|
||||
publicKey: cellViewModel.threadId,
|
||||
profile: cellViewModel.profile,
|
||||
additionalProfile: cellViewModel.additionalProfile,
|
||||
threadVariant: cellViewModel.threadVariant,
|
||||
openGroupProfilePictureData: cellViewModel.openGroupProfilePictureData,
|
||||
useFallbackPicture: (cellViewModel.threadVariant == .openGroup && cellViewModel.openGroupProfilePictureData == nil)
|
||||
customImageData: cellViewModel.openGroupProfilePictureData,
|
||||
profile: cellViewModel.profile,
|
||||
additionalProfile: cellViewModel.additionalProfile
|
||||
)
|
||||
|
||||
isPinnedIcon.isHidden = true
|
||||
|
@ -362,15 +360,10 @@ public final class FullConversationCell: UITableViewCell {
|
|||
)
|
||||
profilePictureView.update(
|
||||
publicKey: cellViewModel.threadId,
|
||||
profile: cellViewModel.profile,
|
||||
additionalProfile: cellViewModel.additionalProfile,
|
||||
threadVariant: cellViewModel.threadVariant,
|
||||
openGroupProfilePictureData: cellViewModel.openGroupProfilePictureData,
|
||||
useFallbackPicture: (
|
||||
cellViewModel.threadVariant == .openGroup &&
|
||||
cellViewModel.openGroupProfilePictureData == nil
|
||||
),
|
||||
showMultiAvatarForClosedGroup: true
|
||||
customImageData: cellViewModel.openGroupProfilePictureData,
|
||||
profile: cellViewModel.profile,
|
||||
additionalProfile: cellViewModel.additionalProfile
|
||||
)
|
||||
displayNameLabel.text = cellViewModel.displayName
|
||||
timestampLabel.text = cellViewModel.lastInteractionDate.formattedForDisplay
|
||||
|
|
|
@ -16,10 +16,14 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
|
|||
typealias SectionModel = SessionTableViewModel<NavItemId, Section, SettingItem>.SectionModel
|
||||
|
||||
private let viewModel: SessionTableViewModel<NavItemId, Section, SettingItem>
|
||||
private var hasLoadedInitialSettingsData: Bool = false
|
||||
private var hasLoadedInitialTableData: Bool = false
|
||||
private var isLoadingMore: Bool = false
|
||||
private var isAutoLoadingNextPage: Bool = false
|
||||
private var viewHasAppeared: Bool = false
|
||||
private var dataStreamJustFailed: Bool = false
|
||||
private var dataChangeCancellable: AnyCancellable?
|
||||
private var disposables: Set<AnyCancellable> = Set()
|
||||
private var onFooterTap: (() -> ())?
|
||||
|
||||
public var viewModelType: AnyObject.Type { return type(of: viewModel) }
|
||||
|
||||
|
@ -32,7 +36,6 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
|
|||
result.themeBackgroundColor = .clear
|
||||
result.showsVerticalScrollIndicator = false
|
||||
result.showsHorizontalScrollIndicator = false
|
||||
result.register(view: SessionAvatarCell.self)
|
||||
result.register(view: SessionCell.self)
|
||||
result.registerHeaderFooterView(view: SessionHeaderView.self)
|
||||
result.dataSource = self
|
||||
|
@ -45,11 +48,50 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
|
|||
return result
|
||||
}()
|
||||
|
||||
private lazy var emptyStateLabel: UILabel = {
|
||||
let result: UILabel = UILabel()
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.isUserInteractionEnabled = false
|
||||
result.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
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))
|
||||
result.isHidden = true
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var footerButton: SessionButton = {
|
||||
let result: SessionButton = SessionButton(style: .bordered, size: .medium)
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.addTarget(self, action: #selector(footerButtonTapped), for: .touchUpInside)
|
||||
result.isHidden = true
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(viewModel: SessionTableViewModel<NavItemId, Section, SettingItem>) {
|
||||
self.viewModel = viewModel
|
||||
|
||||
Storage.shared.addObserver(viewModel.pagedDataObserver)
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
|
@ -74,6 +116,9 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
|
|||
|
||||
view.themeBackgroundColor = .backgroundPrimary
|
||||
view.addSubview(tableView)
|
||||
view.addSubview(emptyStateLabel)
|
||||
view.addSubview(fadeView)
|
||||
view.addSubview(footerButton)
|
||||
|
||||
setupLayout()
|
||||
setupBinding()
|
||||
|
@ -98,6 +143,13 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
|
|||
startObservingChanges()
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
|
||||
viewHasAppeared = true
|
||||
autoLoadNextPageIfNeeded()
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
|
||||
|
@ -114,18 +166,29 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
|
|||
|
||||
private func setupLayout() {
|
||||
tableView.pin(to: view)
|
||||
|
||||
emptyStateLabel.pin(.top, to: .top, of: self.view, withInset: Values.massiveSpacing)
|
||||
emptyStateLabel.pin(.leading, to: .leading, of: self.view, withInset: Values.mediumSpacing)
|
||||
emptyStateLabel.pin(.trailing, to: .trailing, of: self.view, withInset: -Values.mediumSpacing)
|
||||
|
||||
fadeView.pin(.leading, to: .leading, of: self.view)
|
||||
fadeView.pin(.trailing, to: .trailing, of: self.view)
|
||||
fadeView.pin(.bottom, to: .bottom, of: self.view)
|
||||
|
||||
footerButton.center(.horizontal, in: self.view)
|
||||
footerButton.pin(.bottom, to: .bottom, of: self.view.safeAreaLayoutGuide, withInset: -Values.smallSpacing)
|
||||
}
|
||||
|
||||
// MARK: - Updating
|
||||
|
||||
private func startObservingChanges() {
|
||||
// Start observing for data changes
|
||||
dataChangeCancellable = viewModel.observableSettingsData
|
||||
dataChangeCancellable = viewModel.observableTableData
|
||||
.receiveOnMain(
|
||||
// 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)
|
||||
immediately: !hasLoadedInitialSettingsData
|
||||
immediately: !hasLoadedInitialTableData
|
||||
)
|
||||
.sink(
|
||||
receiveCompletion: { [weak self] result in
|
||||
|
@ -146,9 +209,9 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
|
|||
case .finished: break
|
||||
}
|
||||
},
|
||||
receiveValue: { [weak self] settingsData in
|
||||
receiveValue: { [weak self] updatedData, changeset in
|
||||
self?.dataStreamJustFailed = false
|
||||
self?.handleSettingsUpdates(settingsData)
|
||||
self?.handleDataUpdates(updatedData, changeset: changeset)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
@ -158,27 +221,80 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
|
|||
dataChangeCancellable?.cancel()
|
||||
}
|
||||
|
||||
private func handleSettingsUpdates(_ updatedData: [SectionModel], initialLoad: Bool = false) {
|
||||
private func handleDataUpdates(
|
||||
_ updatedData: [SectionModel],
|
||||
changeset: StagedChangeset<[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 hasLoadedInitialSettingsData else {
|
||||
hasLoadedInitialSettingsData = true
|
||||
UIView.performWithoutAnimation { handleSettingsUpdates(updatedData, initialLoad: true) }
|
||||
guard hasLoadedInitialTableData else {
|
||||
hasLoadedInitialTableData = true
|
||||
UIView.performWithoutAnimation {
|
||||
handleDataUpdates(updatedData, changeset: changeset, initialLoad: true)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Show the empty state if there is no data
|
||||
let itemCount: Int = updatedData
|
||||
.map { $0.elements.count }
|
||||
.reduce(0, +)
|
||||
emptyStateLabel.isHidden = (itemCount > 0)
|
||||
|
||||
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: StagedChangeset(source: viewModel.settingsData, target: updatedData),
|
||||
using: changeset,
|
||||
deleteSectionsAnimation: .none,
|
||||
insertSectionsAnimation: .none,
|
||||
reloadSectionsAnimation: .none,
|
||||
deleteRowsAnimation: .bottom,
|
||||
insertRowsAnimation: .none,
|
||||
reloadRowsAnimation: .none,
|
||||
deleteRowsAnimation: .fade,
|
||||
insertRowsAnimation: .fade,
|
||||
reloadRowsAnimation: .fade,
|
||||
interrupt: { $0.changeCount > 100 } // Prevent too many changes from causing performance issues
|
||||
) { [weak self] updatedData in
|
||||
self?.viewModel.updateSettings(updatedData)
|
||||
self?.viewModel.updateTableData(updatedData)
|
||||
}
|
||||
|
||||
CATransaction.commit()
|
||||
}
|
||||
|
||||
private func autoLoadNextPageIfNeeded() {
|
||||
guard !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: [(Section, CGRect)] = (self?.viewModel.tableData
|
||||
.enumerated()
|
||||
.map { index, section in
|
||||
(section.model, (self?.tableView.rectForHeader(inSection: index) ?? .zero))
|
||||
})
|
||||
.defaulting(to: [])
|
||||
let shouldLoadMore: Bool = sections
|
||||
.contains { section, headerRect in
|
||||
section.style == .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.loadPageAfter()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -188,18 +304,27 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
|
|||
viewModel.isEditing
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] isEditing in
|
||||
self?.setEditing(isEditing, animated: true)
|
||||
|
||||
self?.tableView.visibleCells.forEach { cell in
|
||||
switch cell {
|
||||
case let cell as SessionCell:
|
||||
cell.update(isEditing: isEditing, animated: true)
|
||||
|
||||
case let avatarCell as SessionAvatarCell:
|
||||
avatarCell.update(isEditing: isEditing, animated: true)
|
||||
|
||||
default: break
|
||||
}
|
||||
UIView.animate(withDuration: 0.25) {
|
||||
self?.setEditing(isEditing, animated: true)
|
||||
|
||||
self?.tableView.visibleCells
|
||||
.compactMap { $0 as? SessionCell }
|
||||
.filter { $0.interactionMode == .editable || $0.interactionMode == .alwaysEditing }
|
||||
.enumerated()
|
||||
.forEach { index, cell in
|
||||
cell.update(
|
||||
isEditing: (isEditing || cell.interactionMode == .alwaysEditing),
|
||||
becomeFirstResponder: (
|
||||
isEditing &&
|
||||
index == 0 &&
|
||||
cell.interactionMode != .alwaysEditing
|
||||
),
|
||||
animated: true
|
||||
)
|
||||
}
|
||||
|
||||
self?.tableView.beginUpdates()
|
||||
self?.tableView.endUpdates()
|
||||
}
|
||||
}
|
||||
.store(in: &disposables)
|
||||
|
@ -248,6 +373,13 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
|
|||
}
|
||||
.store(in: &disposables)
|
||||
|
||||
viewModel.emptyStateTextPublisher
|
||||
.receiveOnMain(immediately: true)
|
||||
.sink { [weak self] text in
|
||||
self?.emptyStateLabel.text = text
|
||||
}
|
||||
.store(in: &disposables)
|
||||
|
||||
viewModel.footerView
|
||||
.receiveOnMain(immediately: true)
|
||||
.sink { [weak self] footerView in
|
||||
|
@ -255,6 +387,33 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
|
|||
}
|
||||
.store(in: &disposables)
|
||||
|
||||
viewModel.footerButtonInfo
|
||||
.receiveOnMain(immediately: true)
|
||||
.sink { [weak self] buttonInfo in
|
||||
if let buttonInfo: SessionButton.Info = buttonInfo {
|
||||
self?.footerButton.setTitle(buttonInfo.title, for: .normal)
|
||||
self?.footerButton.setStyle(buttonInfo.style)
|
||||
self?.footerButton.isEnabled = buttonInfo.isEnabled
|
||||
}
|
||||
|
||||
self?.onFooterTap = buttonInfo?.onTap
|
||||
self?.fadeView.isHidden = (buttonInfo == nil)
|
||||
self?.footerButton.isHidden = (buttonInfo == nil)
|
||||
|
||||
// If we have a footerButton then we want to manually control the contentInset
|
||||
self?.tableView.contentInsetAdjustmentBehavior = (buttonInfo == nil ? .automatic : .never)
|
||||
self?.tableView.contentInset = UIEdgeInsets(
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: (buttonInfo == nil ?
|
||||
0 :
|
||||
Values.footerGradientHeight(window: UIApplication.shared.keyWindow)
|
||||
),
|
||||
right: 0
|
||||
)
|
||||
}
|
||||
.store(in: &disposables)
|
||||
|
||||
viewModel.showToast
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] text, color in
|
||||
|
@ -303,91 +462,60 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
|
|||
|
||||
case .dismiss: self?.dismiss(animated: true)
|
||||
case .pop: self?.navigationController?.popViewController(animated: true)
|
||||
case .popToRoot: self?.navigationController?.popToRootViewController(animated: true)
|
||||
}
|
||||
}
|
||||
.store(in: &disposables)
|
||||
}
|
||||
|
||||
@objc private func footerButtonTapped() {
|
||||
onFooterTap?()
|
||||
}
|
||||
|
||||
// MARK: - UITableViewDataSource
|
||||
|
||||
func numberOfSections(in tableView: UITableView) -> Int {
|
||||
return self.viewModel.settingsData.count
|
||||
return self.viewModel.tableData.count
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
return self.viewModel.settingsData[section].elements.count
|
||||
return self.viewModel.tableData[section].elements.count
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let section: SectionModel = viewModel.settingsData[indexPath.section]
|
||||
let section: SectionModel = viewModel.tableData[indexPath.section]
|
||||
let info: SessionCell.Info<SettingItem> = section.elements[indexPath.row]
|
||||
let cell: SessionCell = tableView.dequeue(type: SessionCell.self, for: indexPath)
|
||||
cell.update(with: info)
|
||||
cell.update(
|
||||
isEditing: (self.isEditing || (info.title?.interaction == .alwaysEditing)),
|
||||
becomeFirstResponder: false,
|
||||
animated: false
|
||||
)
|
||||
cell.textPublisher
|
||||
.sink(receiveValue: { [weak self] text in
|
||||
self?.viewModel.textChanged(text, for: info.id)
|
||||
})
|
||||
.store(in: &cell.disposables)
|
||||
|
||||
switch info.leftAccessory {
|
||||
case .threadInfo(let threadViewModel, let style, let avatarTapped, let titleTapped, let titleChanged):
|
||||
let cell: SessionAvatarCell = tableView.dequeue(type: SessionAvatarCell.self, for: indexPath)
|
||||
cell.update(
|
||||
threadViewModel: threadViewModel,
|
||||
style: style,
|
||||
viewController: self
|
||||
)
|
||||
cell.update(isEditing: self.isEditing, animated: false)
|
||||
|
||||
cell.profilePictureTapPublisher
|
||||
.filter { _ in threadViewModel.threadVariant == .contact }
|
||||
.sink(receiveValue: { _ in avatarTapped?() })
|
||||
.store(in: &cell.disposables)
|
||||
|
||||
cell.displayNameTapPublisher
|
||||
.filter { _ in threadViewModel.threadVariant == .contact }
|
||||
.sink(receiveValue: { _ in titleTapped?() })
|
||||
.store(in: &cell.disposables)
|
||||
|
||||
cell.textPublisher
|
||||
.sink(receiveValue: { text in titleChanged?(text) })
|
||||
.store(in: &cell.disposables)
|
||||
|
||||
return cell
|
||||
|
||||
default:
|
||||
let cell: SessionCell = tableView.dequeue(type: SessionCell.self, for: indexPath)
|
||||
cell.update(
|
||||
with: info,
|
||||
style: .rounded,
|
||||
position: Position.with(indexPath.row, count: section.elements.count)
|
||||
)
|
||||
cell.update(isEditing: self.isEditing, animated: false)
|
||||
|
||||
return cell
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
|
||||
let section: SectionModel = viewModel.settingsData[section]
|
||||
let section: SectionModel = viewModel.tableData[section]
|
||||
let result: SessionHeaderView = tableView.dequeueHeaderFooterView(type: SessionHeaderView.self)
|
||||
result.update(
|
||||
title: section.model.title,
|
||||
style: section.model.style
|
||||
)
|
||||
|
||||
switch section.model.style {
|
||||
case .none:
|
||||
return UIView()
|
||||
|
||||
case .padding, .title:
|
||||
let result: SessionHeaderView = tableView.dequeueHeaderFooterView(type: SessionHeaderView.self)
|
||||
result.update(
|
||||
title: section.model.title,
|
||||
hasSeparator: (section.elements.first?.shouldHaveBackground != false)
|
||||
)
|
||||
|
||||
return result
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// MARK: - UITableViewDelegate
|
||||
|
||||
func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
|
||||
let section: SectionModel = viewModel.settingsData[section]
|
||||
|
||||
switch section.model.style {
|
||||
case .none: return 0
|
||||
case .padding, .title: return UITableView.automaticDimension
|
||||
}
|
||||
return viewModel.tableData[section].model.style.height
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||
|
@ -397,11 +525,28 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
|
|||
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||
return UITableView.automaticDimension
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, willDisplayHeaderView view: UIView, forSection section: Int) {
|
||||
guard self.hasLoadedInitialTableData && self.viewHasAppeared && !self.isLoadingMore else { return }
|
||||
|
||||
let section: SectionModel = self.viewModel.tableData[section]
|
||||
|
||||
switch section.model.style {
|
||||
case .loadMore:
|
||||
self.isLoadingMore = true
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||
self?.viewModel.loadPageAfter()
|
||||
}
|
||||
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
|
||||
let section: SectionModel = self.viewModel.settingsData[indexPath.section]
|
||||
let section: SectionModel = self.viewModel.tableData[indexPath.section]
|
||||
let info: SessionCell.Info<SettingItem> = section.elements[indexPath.row]
|
||||
|
||||
// Do nothing if the item is disabled
|
||||
|
@ -414,10 +559,10 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
|
|||
}
|
||||
|
||||
switch (info.leftAccessory, info.rightAccessory) {
|
||||
case (_, .highlightingBackgroundLabel(_)):
|
||||
case (_, .highlightingBackgroundLabel(_, _)):
|
||||
return (!cell.rightAccessoryView.isHidden ? cell.rightAccessoryView : cell)
|
||||
|
||||
case (.highlightingBackgroundLabel(_), _):
|
||||
case (.highlightingBackgroundLabel(_, _), _):
|
||||
return (!cell.leftAccessoryView.isHidden ? cell.leftAccessoryView : cell)
|
||||
|
||||
default:
|
||||
|
@ -428,14 +573,15 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
|
|||
.enumerated()
|
||||
.first(where: { index, info in
|
||||
switch (info.leftAccessory, info.rightAccessory) {
|
||||
case (_, .radio(_, let isSelected, _)): return isSelected()
|
||||
case (.radio(_, let isSelected, _), _): return isSelected()
|
||||
case (_, .radio(_, let isSelected, _, _)): return isSelected()
|
||||
case (.radio(_, let isSelected, _, _), _): return isSelected()
|
||||
default: return false
|
||||
}
|
||||
})
|
||||
|
||||
let performAction: () -> Void = { [weak self, weak tappedView] in
|
||||
info.onTap?(tappedView)
|
||||
info.onTap?()
|
||||
info.onTapView?(tappedView)
|
||||
self?.manuallyReload(indexPath: indexPath, section: section, info: info)
|
||||
|
||||
// Update the old selection as well
|
||||
|
@ -463,10 +609,6 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
|
|||
let confirmationModal: ConfirmationModal = ConfirmationModal(
|
||||
targetView: tappedView,
|
||||
info: confirmationInfo
|
||||
.with(onConfirm: { [weak self] _ in
|
||||
performAction()
|
||||
self?.dismiss(animated: true)
|
||||
})
|
||||
)
|
||||
present(confirmationModal, animated: true, completion: nil)
|
||||
}
|
||||
|
@ -478,11 +620,7 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
|
|||
) {
|
||||
// Try update the existing cell to have a nice animation instead of reloading the cell
|
||||
if let existingCell: SessionCell = tableView.cellForRow(at: indexPath) as? SessionCell {
|
||||
existingCell.update(
|
||||
with: info,
|
||||
style: .rounded,
|
||||
position: Position.with(indexPath.row, count: section.elements.count)
|
||||
)
|
||||
existingCell.update(with: info)
|
||||
}
|
||||
else {
|
||||
tableView.reloadRows(at: [indexPath], with: .none)
|
||||
|
|
|
@ -10,7 +10,7 @@ import SessionUtilitiesKit
|
|||
|
||||
class SessionTableViewModel<NavItemId: Equatable, Section: SessionTableSection, SettingItem: Hashable & Differentiable> {
|
||||
typealias SectionModel = ArraySection<Section, SessionCell.Info<SettingItem>>
|
||||
typealias ObservableData = AnyPublisher<[SectionModel], Error>
|
||||
typealias ObservableData = AnyPublisher<([SectionModel], StagedChangeset<[SectionModel]>), Error>
|
||||
|
||||
// MARK: - Input
|
||||
|
||||
|
@ -18,6 +18,9 @@ class SessionTableViewModel<NavItemId: Equatable, Section: SessionTableSection,
|
|||
lazy var isEditing: AnyPublisher<Bool, Never> = _isEditing
|
||||
.removeDuplicates()
|
||||
.shareReplay(1)
|
||||
private let _textChanged: PassthroughSubject<(text: String?, item: SettingItem), Never> = PassthroughSubject()
|
||||
lazy var textChanged: AnyPublisher<(text: String?, item: SettingItem), Never> = _textChanged
|
||||
.eraseToAnyPublisher()
|
||||
|
||||
// MARK: - Navigation
|
||||
|
||||
|
@ -37,15 +40,25 @@ class SessionTableViewModel<NavItemId: Equatable, Section: SessionTableSection,
|
|||
// MARK: - Content
|
||||
|
||||
open var title: String { preconditionFailure("abstract class - override in subclass") }
|
||||
open var settingsData: [SectionModel] { preconditionFailure("abstract class - override in subclass") }
|
||||
open var observableSettingsData: ObservableData {
|
||||
preconditionFailure("abstract class - override in subclass")
|
||||
}
|
||||
open var footerView: AnyPublisher<UIView?, Never> { Just(nil).eraseToAnyPublisher() }
|
||||
open var emptyStateTextPublisher: AnyPublisher<String?, Never> { Just(nil).eraseToAnyPublisher() }
|
||||
|
||||
func updateSettings(_ updatedSettings: [SectionModel]) {
|
||||
fileprivate var hasEmittedInitialData: Bool = false
|
||||
public private(set) var tableData: [SectionModel] = []
|
||||
open var observableTableData: ObservableData {
|
||||
preconditionFailure("abstract class - override in subclass")
|
||||
}
|
||||
open var pagedDataObserver: TransactionObserver? { nil }
|
||||
open var footerView: AnyPublisher<UIView?, Never> { Just(nil).eraseToAnyPublisher() }
|
||||
open var footerButtonInfo: AnyPublisher<SessionButton.Info?, Never> {
|
||||
Just(nil).eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func updateTableData(_ updatedData: [SectionModel]) {
|
||||
self.tableData = updatedData
|
||||
}
|
||||
|
||||
func loadPageBefore() { preconditionFailure("abstract class - override in subclass") }
|
||||
func loadPageAfter() { preconditionFailure("abstract class - override in subclass") }
|
||||
|
||||
// MARK: - Functions
|
||||
|
||||
|
@ -53,6 +66,10 @@ class SessionTableViewModel<NavItemId: Equatable, Section: SessionTableSection,
|
|||
_isEditing.send(isEditing)
|
||||
}
|
||||
|
||||
func textChanged(_ text: String?, for item: SettingItem) {
|
||||
_textChanged.send((text, item))
|
||||
}
|
||||
|
||||
func showToast(text: String, backgroundColor: ThemeValue = .backgroundPrimary) {
|
||||
_showToast.send((text, backgroundColor))
|
||||
}
|
||||
|
@ -65,3 +82,49 @@ class SessionTableViewModel<NavItemId: Equatable, Section: SessionTableSection,
|
|||
_transitionToScreen.send((viewController, transitionType))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Convenience
|
||||
|
||||
extension Array {
|
||||
func mapToSessionTableViewData<Nav, Section, Item>(
|
||||
for viewModel: SessionTableViewModel<Nav, Section, Item>?
|
||||
) -> [ArraySection<Section, SessionCell.Info<Item>>] where Element == ArraySection<Section, SessionCell.Info<Item>> {
|
||||
// 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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension AnyPublisher {
|
||||
func mapToSessionTableViewData<Nav, Section, Item>(
|
||||
for viewModel: SessionTableViewModel<Nav, Section, Item>
|
||||
) -> AnyPublisher<(Output, StagedChangeset<Output>), Failure> where Output == [ArraySection<Section, SessionCell.Info<Item>>] {
|
||||
return self
|
||||
.map { [weak viewModel] updatedData -> (Output, StagedChangeset<Output>) in
|
||||
let updatedDataWithPositions: Output = updatedData
|
||||
.mapToSessionTableViewData(for: viewModel)
|
||||
|
||||
// Generate an updated changeset
|
||||
let changeset = StagedChangeset(
|
||||
source: (viewModel?.tableData ?? []),
|
||||
target: updatedDataWithPositions
|
||||
)
|
||||
|
||||
return (updatedDataWithPositions, changeset)
|
||||
}
|
||||
.filter { [weak viewModel] _, changeset in
|
||||
viewModel?.hasEmittedInitialData == false || // Always emit at least once
|
||||
!changeset.isEmpty // Do nothing if there were no changes
|
||||
}
|
||||
.handleEvents(receiveOutput: { [weak viewModel] _ in
|
||||
viewModel?.hasEmittedInitialData = true
|
||||
})
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,6 +10,9 @@ public enum DismissType {
|
|||
/// This will only trigger a `popViewController` call (if the screen was presented it'll do nothing)
|
||||
case pop
|
||||
|
||||
/// This will only trigger a `popToRootViewController` call (if the screen was presented it'll do nothing)
|
||||
case popToRoot
|
||||
|
||||
/// This will only trigger a `dismiss` call (if the screen was pushed to a presented navigation controller it'll dismiss
|
||||
/// the navigation controller, otherwise this will do nothing)
|
||||
case dismiss
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
|
||||
public extension SessionCell {
|
||||
struct Accessibility: Hashable, Equatable {
|
||||
let identifier: String?
|
||||
let label: String?
|
||||
|
||||
public init(
|
||||
identifier: String? = nil,
|
||||
label: String? = nil
|
||||
) {
|
||||
self.identifier = identifier
|
||||
self.label = label
|
||||
}
|
||||
}
|
||||
}
|
|
@ -12,45 +12,76 @@ extension SessionCell {
|
|||
UIImage?,
|
||||
size: IconSize,
|
||||
customTint: ThemeValue?,
|
||||
shouldFill: Bool
|
||||
shouldFill: Bool,
|
||||
accessibility: SessionCell.Accessibility?
|
||||
)
|
||||
case iconAsync(
|
||||
size: IconSize,
|
||||
customTint: ThemeValue?,
|
||||
shouldFill: Bool,
|
||||
accessibility: SessionCell.Accessibility?,
|
||||
setter: (UIImageView) -> Void
|
||||
)
|
||||
case toggle(DataSource)
|
||||
case dropDown(DataSource)
|
||||
case toggle(
|
||||
DataSource,
|
||||
accessibility: SessionCell.Accessibility?
|
||||
)
|
||||
case dropDown(
|
||||
DataSource,
|
||||
accessibility: SessionCell.Accessibility?
|
||||
)
|
||||
case radio(
|
||||
size: RadioSize,
|
||||
isSelected: () -> Bool,
|
||||
storedSelection: Bool
|
||||
storedSelection: Bool,
|
||||
accessibility: SessionCell.Accessibility?
|
||||
)
|
||||
|
||||
case highlightingBackgroundLabel(title: String)
|
||||
case profile(String, Profile?)
|
||||
case customView(viewGenerator: () -> UIView)
|
||||
case threadInfo(
|
||||
threadViewModel: SessionThreadViewModel,
|
||||
style: ThreadInfoStyle = ThreadInfoStyle(),
|
||||
avatarTapped: (() -> Void)? = nil,
|
||||
titleTapped: (() -> Void)? = nil,
|
||||
titleChanged: ((String) -> Void)? = nil
|
||||
case highlightingBackgroundLabel(
|
||||
title: String,
|
||||
accessibility: SessionCell.Accessibility?
|
||||
)
|
||||
case profile(
|
||||
id: String,
|
||||
size: IconSize,
|
||||
threadVariant: SessionThread.Variant,
|
||||
customImageData: Data?,
|
||||
profile: Profile?,
|
||||
additionalProfile: Profile?,
|
||||
cornerIcon: UIImage?,
|
||||
accessibility: SessionCell.Accessibility?
|
||||
)
|
||||
|
||||
case search(
|
||||
placeholder: String,
|
||||
accessibility: SessionCell.Accessibility?,
|
||||
searchTermChanged: (String?) -> Void
|
||||
)
|
||||
case button(
|
||||
style: SessionButton.Style,
|
||||
title: String,
|
||||
accessibility: SessionCell.Accessibility?,
|
||||
run: (SessionButton?) -> Void
|
||||
)
|
||||
case customView(
|
||||
hashValue: AnyHashable,
|
||||
viewGenerator: () -> UIView
|
||||
)
|
||||
|
||||
// MARK: - Convenience Vatiables
|
||||
|
||||
var shouldFitToEdge: Bool {
|
||||
switch self {
|
||||
case .icon(_, _, _, let shouldFill), .iconAsync(_, _, let shouldFill, _): return shouldFill
|
||||
case .icon(_, _, _, let shouldFill, _), .iconAsync(_, _, let shouldFill, _, _):
|
||||
return shouldFill
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
var currentBoolValue: Bool {
|
||||
switch self {
|
||||
case .toggle(let dataSource), .dropDown(let dataSource): return dataSource.currentBoolValue
|
||||
case .toggle(let dataSource, _), .dropDown(let dataSource, _): return dataSource.currentBoolValue
|
||||
case .radio(_, let isSelected, _, _): return isSelected()
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
@ -59,90 +90,166 @@ extension SessionCell {
|
|||
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
switch self {
|
||||
case .icon(let image, let size, let customTint, let shouldFill):
|
||||
case .icon(let image, let size, let customTint, let shouldFill, let accessibility):
|
||||
image.hash(into: &hasher)
|
||||
size.hash(into: &hasher)
|
||||
customTint.hash(into: &hasher)
|
||||
shouldFill.hash(into: &hasher)
|
||||
accessibility.hash(into: &hasher)
|
||||
|
||||
case .iconAsync(let size, let customTint, let shouldFill, _):
|
||||
case .iconAsync(let size, let customTint, let shouldFill, let accessibility, _):
|
||||
size.hash(into: &hasher)
|
||||
customTint.hash(into: &hasher)
|
||||
shouldFill.hash(into: &hasher)
|
||||
accessibility.hash(into: &hasher)
|
||||
|
||||
case .toggle(let dataSource):
|
||||
case .toggle(let dataSource, let accessibility):
|
||||
dataSource.hash(into: &hasher)
|
||||
accessibility.hash(into: &hasher)
|
||||
|
||||
case .dropDown(let dataSource):
|
||||
case .dropDown(let dataSource, let accessibility):
|
||||
dataSource.hash(into: &hasher)
|
||||
accessibility.hash(into: &hasher)
|
||||
|
||||
case .radio(let size, let isSelected, let storedSelection):
|
||||
case .radio(let size, let isSelected, let storedSelection, let accessibility):
|
||||
size.hash(into: &hasher)
|
||||
isSelected().hash(into: &hasher)
|
||||
storedSelection.hash(into: &hasher)
|
||||
accessibility.hash(into: &hasher)
|
||||
|
||||
case .highlightingBackgroundLabel(let title):
|
||||
case .highlightingBackgroundLabel(let title, let accessibility):
|
||||
title.hash(into: &hasher)
|
||||
accessibility.hash(into: &hasher)
|
||||
|
||||
case .profile(let profileId, let profile):
|
||||
case .profile(
|
||||
let profileId,
|
||||
let size,
|
||||
let threadVariant,
|
||||
let customImageData,
|
||||
let profile,
|
||||
let additionalProfile,
|
||||
let cornerIcon,
|
||||
let accessibility
|
||||
):
|
||||
profileId.hash(into: &hasher)
|
||||
size.hash(into: &hasher)
|
||||
threadVariant.hash(into: &hasher)
|
||||
customImageData.hash(into: &hasher)
|
||||
profile.hash(into: &hasher)
|
||||
additionalProfile.hash(into: &hasher)
|
||||
cornerIcon.hash(into: &hasher)
|
||||
accessibility.hash(into: &hasher)
|
||||
|
||||
case .customView: break
|
||||
|
||||
case .threadInfo(let threadViewModel, let style, _, _, _):
|
||||
threadViewModel.hash(into: &hasher)
|
||||
case .search(let placeholder, let accessibility, _):
|
||||
placeholder.hash(into: &hasher)
|
||||
accessibility.hash(into: &hasher)
|
||||
|
||||
case .button(let style, let title, let accessibility, _):
|
||||
style.hash(into: &hasher)
|
||||
title.hash(into: &hasher)
|
||||
accessibility.hash(into: &hasher)
|
||||
|
||||
case .customView(let hashValue, _):
|
||||
hashValue.hash(into: &hasher)
|
||||
}
|
||||
}
|
||||
|
||||
public static func == (lhs: Accessory, rhs: Accessory) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.icon(let lhsImage, let lhsSize, let lhsCustomTint, let lhsShouldFill), .icon(let rhsImage, let rhsSize, let rhsCustomTint, let rhsShouldFill)):
|
||||
case (.icon(let lhsImage, let lhsSize, let lhsCustomTint, let lhsShouldFill, let lhsAccessibility), .icon(let rhsImage, let rhsSize, let rhsCustomTint, let rhsShouldFill, let rhsAccessibility)):
|
||||
return (
|
||||
lhsImage == rhsImage &&
|
||||
lhsSize == rhsSize &&
|
||||
lhsCustomTint == rhsCustomTint &&
|
||||
lhsShouldFill == rhsShouldFill
|
||||
lhsShouldFill == rhsShouldFill &&
|
||||
lhsAccessibility == rhsAccessibility
|
||||
)
|
||||
|
||||
case (.iconAsync(let lhsSize, let lhsCustomTint, let lhsShouldFill, _), .iconAsync(let rhsSize, let rhsCustomTint, let rhsShouldFill, _)):
|
||||
case (.iconAsync(let lhsSize, let lhsCustomTint, let lhsShouldFill, let lhsAccessibility, _), .iconAsync(let rhsSize, let rhsCustomTint, let rhsShouldFill, let rhsAccessibility, _)):
|
||||
return (
|
||||
lhsSize == rhsSize &&
|
||||
lhsCustomTint == rhsCustomTint &&
|
||||
lhsShouldFill == rhsShouldFill
|
||||
lhsShouldFill == rhsShouldFill &&
|
||||
lhsAccessibility == rhsAccessibility
|
||||
)
|
||||
|
||||
case (.toggle(let lhsDataSource), .toggle(let rhsDataSource)):
|
||||
return (lhsDataSource == rhsDataSource)
|
||||
case (.toggle(let lhsDataSource, let lhsAccessibility), .toggle(let rhsDataSource, let rhsAccessibility)):
|
||||
return (
|
||||
lhsDataSource == rhsDataSource &&
|
||||
lhsAccessibility == rhsAccessibility
|
||||
)
|
||||
|
||||
case (.dropDown(let lhsDataSource), .dropDown(let rhsDataSource)):
|
||||
return (lhsDataSource == rhsDataSource)
|
||||
case (.dropDown(let lhsDataSource, let lhsAccessibility), .dropDown(let rhsDataSource, let rhsAccessibility)):
|
||||
return (
|
||||
lhsDataSource == rhsDataSource &&
|
||||
lhsAccessibility == rhsAccessibility
|
||||
)
|
||||
|
||||
case (.radio(let lhsSize, let lhsIsSelected, let lhsStoredSelection), .radio(let rhsSize, let rhsIsSelected, let rhsStoredSelection)):
|
||||
case (.radio(let lhsSize, let lhsIsSelected, let lhsStoredSelection, let lhsAccessibility), .radio(let rhsSize, let rhsIsSelected, let rhsStoredSelection, let rhsAccessibility)):
|
||||
return (
|
||||
lhsSize == rhsSize &&
|
||||
lhsIsSelected() == rhsIsSelected() &&
|
||||
lhsStoredSelection == rhsStoredSelection
|
||||
lhsStoredSelection == rhsStoredSelection &&
|
||||
lhsAccessibility == rhsAccessibility
|
||||
)
|
||||
|
||||
case (.highlightingBackgroundLabel(let lhsTitle), .highlightingBackgroundLabel(let rhsTitle)):
|
||||
return (lhsTitle == rhsTitle)
|
||||
case (.highlightingBackgroundLabel(let lhsTitle, let lhsAccessibility), .highlightingBackgroundLabel(let rhsTitle, let rhsAccessibility)):
|
||||
return (
|
||||
lhsTitle == rhsTitle &&
|
||||
lhsAccessibility == rhsAccessibility
|
||||
)
|
||||
|
||||
case (.profile(let lhsProfileId, let lhsProfile), .profile(let rhsProfileId, let rhsProfile)):
|
||||
case (
|
||||
.profile(
|
||||
let lhsProfileId,
|
||||
let lhsSize,
|
||||
let lhsThreadVariant,
|
||||
let lhsProfile,
|
||||
let lhsAdditionalProfile,
|
||||
let lhsCustomImageData,
|
||||
let lhsCornerIcon,
|
||||
let lhsAccessibility
|
||||
),
|
||||
.profile(
|
||||
let rhsProfileId,
|
||||
let rhsSize,
|
||||
let rhsThreadVariant,
|
||||
let rhsProfile,
|
||||
let rhsAdditionalProfile,
|
||||
let rhsCustomImageData,
|
||||
let rhsCornerIcon,
|
||||
let rhsAccessibility
|
||||
)
|
||||
):
|
||||
return (
|
||||
lhsProfileId == rhsProfileId &&
|
||||
lhsProfile == rhsProfile
|
||||
lhsSize == rhsSize &&
|
||||
lhsThreadVariant == rhsThreadVariant &&
|
||||
lhsProfile == rhsProfile &&
|
||||
lhsAdditionalProfile == rhsAdditionalProfile &&
|
||||
lhsCustomImageData == rhsCustomImageData &&
|
||||
lhsCornerIcon == rhsCornerIcon &&
|
||||
lhsAccessibility == rhsAccessibility
|
||||
)
|
||||
|
||||
case (.customView, .customView): return false
|
||||
|
||||
case (.threadInfo(let lhsThreadViewModel, let lhsStyle, _, _, _), .threadInfo(let rhsThreadViewModel, let rhsStyle, _, _, _)):
|
||||
case (.search(let lhsPlaceholder, let lhsAccessibility, _), .search(let rhsPlaceholder, let rhsAccessibility, _)):
|
||||
return (
|
||||
lhsThreadViewModel == rhsThreadViewModel &&
|
||||
lhsStyle == rhsStyle
|
||||
lhsPlaceholder == rhsPlaceholder &&
|
||||
lhsAccessibility == rhsAccessibility
|
||||
)
|
||||
|
||||
|
||||
case (.button(let lhsStyle, let lhsTitle, let lhsAccessibility, _), .button(let rhsStyle, let rhsTitle, let rhsAccessibility, _)):
|
||||
return (
|
||||
lhsStyle == rhsStyle &&
|
||||
lhsTitle == rhsTitle &&
|
||||
lhsAccessibility == rhsAccessibility
|
||||
)
|
||||
|
||||
case (.customView(let lhsHashValue, _), .customView(let rhsHashValue, _)):
|
||||
return (
|
||||
lhsHashValue.hashValue == rhsHashValue.hashValue
|
||||
)
|
||||
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
@ -157,59 +264,121 @@ extension SessionCell.Accessory {
|
|||
// MARK: - .icon Variants
|
||||
|
||||
public static func icon(_ image: UIImage?) -> SessionCell.Accessory {
|
||||
return .icon(image, size: .medium, customTint: nil, shouldFill: false)
|
||||
return .icon(image, size: .medium, customTint: nil, shouldFill: false, accessibility: nil)
|
||||
}
|
||||
|
||||
public static func icon(_ image: UIImage?, customTint: ThemeValue) -> SessionCell.Accessory {
|
||||
return .icon(image, size: .medium, customTint: customTint, shouldFill: false)
|
||||
return .icon(image, size: .medium, customTint: customTint, shouldFill: false, accessibility: nil)
|
||||
}
|
||||
|
||||
public static func icon(_ image: UIImage?, size: IconSize) -> SessionCell.Accessory {
|
||||
return .icon(image, size: size, customTint: nil, shouldFill: false)
|
||||
return .icon(image, size: size, customTint: nil, shouldFill: false, accessibility: nil)
|
||||
}
|
||||
|
||||
public static func icon(_ image: UIImage?, size: IconSize, customTint: ThemeValue) -> SessionCell.Accessory {
|
||||
return .icon(image, size: size, customTint: customTint, shouldFill: false)
|
||||
return .icon(image, size: size, customTint: customTint, shouldFill: false, accessibility: nil)
|
||||
}
|
||||
|
||||
public static func icon(_ image: UIImage?, shouldFill: Bool) -> SessionCell.Accessory {
|
||||
return .icon(image, size: .medium, customTint: nil, shouldFill: shouldFill)
|
||||
return .icon(image, size: .medium, customTint: nil, shouldFill: shouldFill, accessibility: nil)
|
||||
}
|
||||
|
||||
public static func icon(_ image: UIImage?, accessibility: SessionCell.Accessibility) -> SessionCell.Accessory {
|
||||
return .icon(image, size: .medium, customTint: nil, shouldFill: false, accessibility: accessibility)
|
||||
}
|
||||
|
||||
// MARK: - .iconAsync Variants
|
||||
|
||||
public static func iconAsync(_ setter: @escaping (UIImageView) -> Void) -> SessionCell.Accessory {
|
||||
return .iconAsync(size: .medium, customTint: nil, shouldFill: false, setter: setter)
|
||||
return .iconAsync(size: .medium, customTint: nil, shouldFill: false, accessibility: nil, setter: setter)
|
||||
}
|
||||
|
||||
public static func iconAsync(customTint: ThemeValue, _ setter: @escaping (UIImageView) -> Void) -> SessionCell.Accessory {
|
||||
return .iconAsync(size: .medium, customTint: customTint, shouldFill: false, setter: setter)
|
||||
return .iconAsync(size: .medium, customTint: customTint, shouldFill: false, accessibility: nil, setter: setter)
|
||||
}
|
||||
|
||||
public static func iconAsync(size: IconSize, setter: @escaping (UIImageView) -> Void) -> SessionCell.Accessory {
|
||||
return .iconAsync(size: size, customTint: nil, shouldFill: false, setter: setter)
|
||||
return .iconAsync(size: size, customTint: nil, shouldFill: false, accessibility: nil, setter: setter)
|
||||
}
|
||||
|
||||
public static func iconAsync(shouldFill: Bool, setter: @escaping (UIImageView) -> Void) -> SessionCell.Accessory {
|
||||
return .iconAsync(size: .medium, customTint: nil, shouldFill: shouldFill, setter: setter)
|
||||
return .iconAsync(size: .medium, customTint: nil, shouldFill: shouldFill, accessibility: nil, setter: setter)
|
||||
}
|
||||
|
||||
public static func iconAsync(size: IconSize, customTint: ThemeValue, setter: @escaping (UIImageView) -> Void) -> SessionCell.Accessory {
|
||||
return .iconAsync(size: size, customTint: customTint, shouldFill: false, setter: setter)
|
||||
return .iconAsync(size: size, customTint: customTint, shouldFill: false, accessibility: nil, setter: setter)
|
||||
}
|
||||
|
||||
public static func iconAsync(size: IconSize, shouldFill: Bool, setter: @escaping (UIImageView) -> Void) -> SessionCell.Accessory {
|
||||
return .iconAsync(size: size, customTint: nil, shouldFill: shouldFill, setter: setter)
|
||||
return .iconAsync(size: size, customTint: nil, shouldFill: shouldFill, accessibility: nil, setter: setter)
|
||||
}
|
||||
|
||||
// MARK: - .toggle Variants
|
||||
|
||||
public static func toggle(_ dataSource: DataSource) -> SessionCell.Accessory {
|
||||
return .toggle(dataSource, accessibility: nil)
|
||||
}
|
||||
|
||||
// MARK: - .dropDown Variants
|
||||
|
||||
public static func dropDown(_ dataSource: DataSource) -> SessionCell.Accessory {
|
||||
return .dropDown(dataSource, accessibility: nil)
|
||||
}
|
||||
|
||||
// MARK: - .radio Variants
|
||||
|
||||
public static func radio(isSelected: @escaping () -> Bool) -> SessionCell.Accessory {
|
||||
return .radio(size: .medium, isSelected: isSelected, storedSelection: false)
|
||||
return .radio(size: .medium, isSelected: isSelected, storedSelection: false, accessibility: nil)
|
||||
}
|
||||
|
||||
public static func radio(isSelected: @escaping () -> Bool, storedSelection: Bool) -> SessionCell.Accessory {
|
||||
return .radio(size: .medium, isSelected: isSelected, storedSelection: storedSelection)
|
||||
return .radio(size: .medium, isSelected: isSelected, storedSelection: storedSelection, accessibility: nil)
|
||||
}
|
||||
|
||||
// MARK: - .highlightingBackgroundLabel Variants
|
||||
|
||||
public static func highlightingBackgroundLabel(title: String) -> SessionCell.Accessory {
|
||||
return .highlightingBackgroundLabel(title: title, accessibility: nil)
|
||||
}
|
||||
|
||||
// MARK: - .profile Variants
|
||||
|
||||
public static func profile(id: String, profile: Profile?) -> SessionCell.Accessory {
|
||||
return .profile(
|
||||
id: id,
|
||||
size: .veryLarge,
|
||||
threadVariant: .contact,
|
||||
customImageData: nil,
|
||||
profile: profile,
|
||||
additionalProfile: nil,
|
||||
cornerIcon: nil,
|
||||
accessibility: nil
|
||||
)
|
||||
}
|
||||
|
||||
public static func profile(id: String, size: IconSize, profile: Profile?) -> SessionCell.Accessory {
|
||||
return .profile(
|
||||
id: id,
|
||||
size: size,
|
||||
threadVariant: .contact,
|
||||
customImageData: nil,
|
||||
profile: profile,
|
||||
additionalProfile: nil,
|
||||
cornerIcon: nil,
|
||||
accessibility: nil
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - .search Variants
|
||||
|
||||
public static func search(placeholder: String, searchTermChanged: @escaping (String?) -> Void) -> SessionCell.Accessory {
|
||||
return .search(placeholder: placeholder, accessibility: nil, searchTermChanged: searchTermChanged)
|
||||
}
|
||||
|
||||
// MARK: - .button Variants
|
||||
|
||||
public static func button(style: SessionButton.Style, title: String, run: @escaping (SessionButton?) -> Void) -> SessionCell.Accessory {
|
||||
return .button(style: style, title: title, accessibility: nil, run: run)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -293,42 +462,3 @@ extension SessionCell.Accessory {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SessionCell.Accessory.ThreadInfoStyle
|
||||
|
||||
extension SessionCell.Accessory {
|
||||
public struct ThreadInfoStyle: Hashable, Equatable {
|
||||
public enum Style: Hashable, Equatable {
|
||||
case small
|
||||
case monoSmall
|
||||
case monoLarge
|
||||
}
|
||||
|
||||
public struct Action: Hashable, Equatable {
|
||||
let title: String
|
||||
let run: (SessionButton?) -> ()
|
||||
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
title.hash(into: &hasher)
|
||||
}
|
||||
|
||||
public static func == (lhs: Action, rhs: Action) -> Bool {
|
||||
return (lhs.title == rhs.title)
|
||||
}
|
||||
}
|
||||
|
||||
public let separatorTitle: String?
|
||||
public let descriptionStyle: Style
|
||||
public let descriptionActions: [Action]
|
||||
|
||||
public init(
|
||||
separatorTitle: String? = nil,
|
||||
descriptionStyle: Style = .monoSmall,
|
||||
descriptionActions: [Action] = []
|
||||
) {
|
||||
self.separatorTitle = separatorTitle
|
||||
self.descriptionStyle = descriptionStyle
|
||||
self.descriptionActions = descriptionActions
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,20 +0,0 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
|
||||
extension SessionCell {
|
||||
struct ExtraAction: Hashable, Equatable {
|
||||
let title: String
|
||||
let onTap: (() -> Void)
|
||||
|
||||
// MARK: - Conformance
|
||||
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
title.hash(into: &hasher)
|
||||
}
|
||||
|
||||
static func == (lhs: SessionCell.ExtraAction, rhs: SessionCell.ExtraAction) -> Bool {
|
||||
return (lhs.title == rhs.title)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -7,21 +7,17 @@ import SessionUIKit
|
|||
extension SessionCell {
|
||||
public struct Info<ID: Hashable & Differentiable>: Equatable, Hashable, Differentiable {
|
||||
let id: ID
|
||||
let position: Position
|
||||
let leftAccessory: SessionCell.Accessory?
|
||||
let title: String
|
||||
let subtitle: String?
|
||||
let subtitleExtraViewGenerator: (() -> UIView)?
|
||||
let tintColor: ThemeValue
|
||||
let title: TextInfo?
|
||||
let subtitle: TextInfo?
|
||||
let rightAccessory: SessionCell.Accessory?
|
||||
let extraAction: SessionCell.ExtraAction?
|
||||
let styling: StyleInfo
|
||||
let isEnabled: Bool
|
||||
let shouldHaveBackground: Bool
|
||||
let accessibilityIdentifier: String?
|
||||
let accessibilityLabel: String?
|
||||
let leftAccessoryAccessibilityLabel: String?
|
||||
let rightAccessoryAccessibilityLabel: String?
|
||||
let accessibility: SessionCell.Accessibility?
|
||||
let confirmationInfo: ConfirmationModal.Info?
|
||||
let onTap: ((UIView?) -> Void)?
|
||||
let onTap: (() -> Void)?
|
||||
let onTapView: ((UIView?) -> Void)?
|
||||
|
||||
var currentBoolValue: Bool {
|
||||
return (
|
||||
|
@ -34,74 +30,30 @@ extension SessionCell {
|
|||
|
||||
init(
|
||||
id: ID,
|
||||
position: Position = .individual,
|
||||
leftAccessory: SessionCell.Accessory? = nil,
|
||||
title: String,
|
||||
subtitle: String? = nil,
|
||||
subtitleExtraViewGenerator: (() -> UIView)? = nil,
|
||||
tintColor: ThemeValue = .textPrimary,
|
||||
title: SessionCell.TextInfo? = nil,
|
||||
subtitle: SessionCell.TextInfo? = nil,
|
||||
rightAccessory: SessionCell.Accessory? = nil,
|
||||
extraAction: SessionCell.ExtraAction? = nil,
|
||||
styling: StyleInfo = StyleInfo(),
|
||||
isEnabled: Bool = true,
|
||||
shouldHaveBackground: Bool = true,
|
||||
accessibilityIdentifier: String? = nil,
|
||||
accessibilityLabel: String? = nil,
|
||||
leftAccessoryAccessibilityLabel: String? = nil,
|
||||
rightAccessoryAccessibilityLabel: String? = nil,
|
||||
accessibility: SessionCell.Accessibility? = nil,
|
||||
confirmationInfo: ConfirmationModal.Info? = nil,
|
||||
onTap: ((UIView?) -> Void)?
|
||||
onTap: (() -> Void)? = nil,
|
||||
onTapView: ((UIView?) -> Void)? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.position = position
|
||||
self.leftAccessory = leftAccessory
|
||||
self.title = title
|
||||
self.subtitle = subtitle
|
||||
self.subtitleExtraViewGenerator = subtitleExtraViewGenerator
|
||||
self.tintColor = tintColor
|
||||
self.rightAccessory = rightAccessory
|
||||
self.extraAction = extraAction
|
||||
self.styling = styling
|
||||
self.isEnabled = isEnabled
|
||||
self.shouldHaveBackground = shouldHaveBackground
|
||||
self.accessibilityIdentifier = accessibilityIdentifier
|
||||
self.accessibilityLabel = accessibilityLabel
|
||||
self.leftAccessoryAccessibilityLabel = leftAccessoryAccessibilityLabel
|
||||
self.rightAccessoryAccessibilityLabel = rightAccessoryAccessibilityLabel
|
||||
self.accessibility = accessibility
|
||||
self.confirmationInfo = confirmationInfo
|
||||
self.onTap = onTap
|
||||
}
|
||||
|
||||
init(
|
||||
id: ID,
|
||||
leftAccessory: SessionCell.Accessory? = nil,
|
||||
title: String,
|
||||
subtitle: String? = nil,
|
||||
subtitleExtraViewGenerator: (() -> UIView)? = nil,
|
||||
tintColor: ThemeValue = .textPrimary,
|
||||
rightAccessory: SessionCell.Accessory? = nil,
|
||||
extraAction: SessionCell.ExtraAction? = nil,
|
||||
isEnabled: Bool = true,
|
||||
shouldHaveBackground: Bool = true,
|
||||
accessibilityIdentifier: String? = nil,
|
||||
accessibilityLabel: String? = nil,
|
||||
leftAccessoryAccessibilityLabel: String? = nil,
|
||||
rightAccessoryAccessibilityLabel: String? = nil,
|
||||
confirmationInfo: ConfirmationModal.Info? = nil,
|
||||
onTap: (() -> Void)? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.leftAccessory = leftAccessory
|
||||
self.title = title
|
||||
self.subtitle = subtitle
|
||||
self.subtitleExtraViewGenerator = subtitleExtraViewGenerator
|
||||
self.tintColor = tintColor
|
||||
self.rightAccessory = rightAccessory
|
||||
self.extraAction = extraAction
|
||||
self.isEnabled = isEnabled
|
||||
self.shouldHaveBackground = shouldHaveBackground
|
||||
self.accessibilityIdentifier = accessibilityIdentifier
|
||||
self.accessibilityLabel = accessibilityLabel
|
||||
self.leftAccessoryAccessibilityLabel = leftAccessoryAccessibilityLabel
|
||||
self.rightAccessoryAccessibilityLabel = rightAccessoryAccessibilityLabel
|
||||
self.confirmationInfo = confirmationInfo
|
||||
self.onTap = (onTap != nil ? { _ in onTap?() } : nil)
|
||||
self.onTapView = onTapView
|
||||
}
|
||||
|
||||
// MARK: - Conformance
|
||||
|
@ -110,37 +62,190 @@ extension SessionCell {
|
|||
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
id.hash(into: &hasher)
|
||||
position.hash(into: &hasher)
|
||||
leftAccessory.hash(into: &hasher)
|
||||
title.hash(into: &hasher)
|
||||
subtitle.hash(into: &hasher)
|
||||
tintColor.hash(into: &hasher)
|
||||
rightAccessory.hash(into: &hasher)
|
||||
extraAction.hash(into: &hasher)
|
||||
styling.hash(into: &hasher)
|
||||
isEnabled.hash(into: &hasher)
|
||||
shouldHaveBackground.hash(into: &hasher)
|
||||
accessibilityIdentifier.hash(into: &hasher)
|
||||
accessibilityLabel.hash(into: &hasher)
|
||||
leftAccessoryAccessibilityLabel.hash(into: &hasher)
|
||||
rightAccessoryAccessibilityLabel.hash(into: &hasher)
|
||||
accessibility.hash(into: &hasher)
|
||||
confirmationInfo.hash(into: &hasher)
|
||||
}
|
||||
|
||||
public static func == (lhs: Info<ID>, rhs: Info<ID>) -> Bool {
|
||||
return (
|
||||
lhs.id == rhs.id &&
|
||||
lhs.position == rhs.position &&
|
||||
lhs.leftAccessory == rhs.leftAccessory &&
|
||||
lhs.title == rhs.title &&
|
||||
lhs.subtitle == rhs.subtitle &&
|
||||
lhs.tintColor == rhs.tintColor &&
|
||||
lhs.rightAccessory == rhs.rightAccessory &&
|
||||
lhs.extraAction == rhs.extraAction &&
|
||||
lhs.styling == rhs.styling &&
|
||||
lhs.isEnabled == rhs.isEnabled &&
|
||||
lhs.shouldHaveBackground == rhs.shouldHaveBackground &&
|
||||
lhs.accessibilityIdentifier == rhs.accessibilityIdentifier &&
|
||||
lhs.accessibilityLabel == rhs.accessibilityLabel &&
|
||||
lhs.leftAccessoryAccessibilityLabel == rhs.leftAccessoryAccessibilityLabel &&
|
||||
lhs.rightAccessoryAccessibilityLabel == rhs.rightAccessoryAccessibilityLabel
|
||||
lhs.accessibility == rhs.accessibility
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Convenience
|
||||
|
||||
public func updatedPosition(for index: Int, count: Int) -> Info {
|
||||
return Info(
|
||||
id: id,
|
||||
position: Position.with(index, count: count),
|
||||
leftAccessory: leftAccessory,
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
rightAccessory: rightAccessory,
|
||||
styling: styling,
|
||||
isEnabled: isEnabled,
|
||||
accessibility: accessibility,
|
||||
confirmationInfo: confirmationInfo,
|
||||
onTap: onTap,
|
||||
onTapView: onTapView
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Convenience Initializers
|
||||
|
||||
public extension SessionCell.Info {
|
||||
// Accessory, () -> Void
|
||||
|
||||
init(
|
||||
id: ID,
|
||||
position: Position = .individual,
|
||||
accessory: SessionCell.Accessory,
|
||||
styling: SessionCell.StyleInfo = SessionCell.StyleInfo(),
|
||||
isEnabled: Bool = true,
|
||||
accessibility: SessionCell.Accessibility? = nil,
|
||||
confirmationInfo: ConfirmationModal.Info? = nil,
|
||||
onTap: (() -> Void)? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.position = position
|
||||
self.leftAccessory = accessory
|
||||
self.title = nil
|
||||
self.subtitle = nil
|
||||
self.rightAccessory = nil
|
||||
self.styling = styling
|
||||
self.isEnabled = isEnabled
|
||||
self.accessibility = accessibility
|
||||
self.confirmationInfo = confirmationInfo
|
||||
self.onTap = onTap
|
||||
self.onTapView = nil
|
||||
}
|
||||
|
||||
// leftAccessory, rightAccessory
|
||||
|
||||
init(
|
||||
id: ID,
|
||||
position: Position = .individual,
|
||||
leftAccessory: SessionCell.Accessory,
|
||||
rightAccessory: SessionCell.Accessory,
|
||||
styling: SessionCell.StyleInfo = SessionCell.StyleInfo(),
|
||||
isEnabled: Bool = true,
|
||||
accessibility: SessionCell.Accessibility? = nil,
|
||||
confirmationInfo: ConfirmationModal.Info? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.position = position
|
||||
self.leftAccessory = leftAccessory
|
||||
self.title = nil
|
||||
self.subtitle = nil
|
||||
self.rightAccessory = rightAccessory
|
||||
self.styling = styling
|
||||
self.isEnabled = isEnabled
|
||||
self.accessibility = accessibility
|
||||
self.confirmationInfo = confirmationInfo
|
||||
self.onTap = nil
|
||||
self.onTapView = nil
|
||||
}
|
||||
|
||||
// String, () -> Void
|
||||
|
||||
init(
|
||||
id: ID,
|
||||
position: Position = .individual,
|
||||
leftAccessory: SessionCell.Accessory? = nil,
|
||||
title: String,
|
||||
rightAccessory: SessionCell.Accessory? = nil,
|
||||
styling: SessionCell.StyleInfo = SessionCell.StyleInfo(),
|
||||
isEnabled: Bool = true,
|
||||
accessibility: SessionCell.Accessibility? = nil,
|
||||
confirmationInfo: ConfirmationModal.Info? = nil,
|
||||
onTap: (() -> Void)? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.position = position
|
||||
self.leftAccessory = leftAccessory
|
||||
self.title = SessionCell.TextInfo(title, font: .title)
|
||||
self.subtitle = nil
|
||||
self.rightAccessory = rightAccessory
|
||||
self.styling = styling
|
||||
self.isEnabled = isEnabled
|
||||
self.accessibility = accessibility
|
||||
self.confirmationInfo = confirmationInfo
|
||||
self.onTap = onTap
|
||||
self.onTapView = nil
|
||||
}
|
||||
|
||||
// TextInfo, () -> Void
|
||||
|
||||
init(
|
||||
id: ID,
|
||||
position: Position = .individual,
|
||||
leftAccessory: SessionCell.Accessory? = nil,
|
||||
title: SessionCell.TextInfo,
|
||||
rightAccessory: SessionCell.Accessory? = nil,
|
||||
styling: SessionCell.StyleInfo = SessionCell.StyleInfo(),
|
||||
isEnabled: Bool = true,
|
||||
accessibility: SessionCell.Accessibility? = nil,
|
||||
confirmationInfo: ConfirmationModal.Info? = nil,
|
||||
onTap: (() -> Void)? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.position = position
|
||||
self.leftAccessory = leftAccessory
|
||||
self.title = title
|
||||
self.subtitle = nil
|
||||
self.rightAccessory = rightAccessory
|
||||
self.styling = styling
|
||||
self.isEnabled = isEnabled
|
||||
self.accessibility = accessibility
|
||||
self.confirmationInfo = confirmationInfo
|
||||
self.onTap = onTap
|
||||
self.onTapView = nil
|
||||
}
|
||||
|
||||
// String, String?, () -> Void
|
||||
|
||||
init(
|
||||
id: ID,
|
||||
position: Position = .individual,
|
||||
leftAccessory: SessionCell.Accessory? = nil,
|
||||
title: String,
|
||||
subtitle: String?,
|
||||
rightAccessory: SessionCell.Accessory? = nil,
|
||||
styling: SessionCell.StyleInfo = SessionCell.StyleInfo(),
|
||||
isEnabled: Bool = true,
|
||||
accessibility: SessionCell.Accessibility? = nil,
|
||||
confirmationInfo: ConfirmationModal.Info? = nil,
|
||||
onTap: (() -> Void)? = nil,
|
||||
onTapView: ((UIView?) -> Void)? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.position = position
|
||||
self.leftAccessory = leftAccessory
|
||||
self.title = SessionCell.TextInfo(title, font: .title)
|
||||
self.subtitle = SessionCell.TextInfo(subtitle, font: .subtitle)
|
||||
self.rightAccessory = rightAccessory
|
||||
self.styling = styling
|
||||
self.isEnabled = isEnabled
|
||||
self.accessibility = accessibility
|
||||
self.confirmationInfo = confirmationInfo
|
||||
self.onTap = onTap
|
||||
self.onTapView = onTapView
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,170 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import SessionUIKit
|
||||
|
||||
// MARK: - Main Types
|
||||
|
||||
public extension SessionCell {
|
||||
struct TextInfo: Hashable, Equatable {
|
||||
public enum Interaction: Hashable, Equatable {
|
||||
case none
|
||||
case editable
|
||||
case copy
|
||||
case alwaysEditing
|
||||
}
|
||||
|
||||
let text: String?
|
||||
let textAlignment: NSTextAlignment
|
||||
let editingPlaceholder: String?
|
||||
let interaction: Interaction
|
||||
let extraViewGenerator: (() -> UIView)?
|
||||
|
||||
private let fontStyle: FontStyle
|
||||
var font: UIFont { fontStyle.font }
|
||||
|
||||
init(
|
||||
_ text: String?,
|
||||
font: FontStyle,
|
||||
alignment: NSTextAlignment = .left,
|
||||
editingPlaceholder: String? = nil,
|
||||
interaction: Interaction = .none,
|
||||
extraViewGenerator: (() -> UIView)? = nil
|
||||
) {
|
||||
self.text = text
|
||||
self.fontStyle = font
|
||||
self.textAlignment = alignment
|
||||
self.editingPlaceholder = editingPlaceholder
|
||||
self.interaction = interaction
|
||||
self.extraViewGenerator = extraViewGenerator
|
||||
}
|
||||
|
||||
// MARK: - Conformance
|
||||
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
text.hash(into: &hasher)
|
||||
fontStyle.hash(into: &hasher)
|
||||
textAlignment.hash(into: &hasher)
|
||||
interaction.hash(into: &hasher)
|
||||
editingPlaceholder.hash(into: &hasher)
|
||||
}
|
||||
|
||||
public static func == (lhs: TextInfo, rhs: TextInfo) -> Bool {
|
||||
return (
|
||||
lhs.text == rhs.text &&
|
||||
lhs.fontStyle == rhs.fontStyle &&
|
||||
lhs.textAlignment == rhs.textAlignment &&
|
||||
lhs.interaction == rhs.interaction &&
|
||||
lhs.editingPlaceholder == rhs.editingPlaceholder
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct StyleInfo: Equatable, Hashable {
|
||||
let tintColor: ThemeValue
|
||||
let alignment: SessionCell.Alignment
|
||||
let allowedSeparators: Separators
|
||||
let customPadding: Padding?
|
||||
let backgroundStyle: SessionCell.BackgroundStyle
|
||||
|
||||
public init(
|
||||
tintColor: ThemeValue = .textPrimary,
|
||||
alignment: SessionCell.Alignment = .leading,
|
||||
allowedSeparators: Separators = [.top, .bottom],
|
||||
customPadding: Padding? = nil,
|
||||
backgroundStyle: SessionCell.BackgroundStyle = .rounded
|
||||
) {
|
||||
self.tintColor = tintColor
|
||||
self.alignment = alignment
|
||||
self.allowedSeparators = allowedSeparators
|
||||
self.customPadding = customPadding
|
||||
self.backgroundStyle = backgroundStyle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Child Types
|
||||
|
||||
public extension SessionCell {
|
||||
enum FontStyle: Hashable, Equatable {
|
||||
case title
|
||||
case titleLarge
|
||||
|
||||
case subtitle
|
||||
case subtitleBold
|
||||
|
||||
case monoSmall
|
||||
case monoLarge
|
||||
|
||||
var font: UIFont {
|
||||
switch self {
|
||||
case .title: return .boldSystemFont(ofSize: 16)
|
||||
case .titleLarge: return .systemFont(ofSize: Values.veryLargeFontSize, weight: .medium)
|
||||
|
||||
case .subtitle: return .systemFont(ofSize: 14)
|
||||
case .subtitleBold: return .boldSystemFont(ofSize: 14)
|
||||
|
||||
case .monoSmall: return Fonts.spaceMono(ofSize: Values.smallFontSize)
|
||||
case .monoLarge: return Fonts.spaceMono(
|
||||
ofSize: (isIPhone5OrSmaller ? Values.mediumFontSize : Values.largeFontSize)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Alignment: Equatable, Hashable {
|
||||
case leading
|
||||
case centerHugging
|
||||
}
|
||||
|
||||
enum BackgroundStyle: Equatable, Hashable {
|
||||
case rounded
|
||||
case edgeToEdge
|
||||
case noBackground
|
||||
}
|
||||
|
||||
struct Separators: OptionSet, Equatable, Hashable {
|
||||
public let rawValue: Int8
|
||||
|
||||
public init(rawValue: Int8) {
|
||||
self.rawValue = rawValue
|
||||
}
|
||||
|
||||
public static let top: Separators = Separators(rawValue: 1 << 0)
|
||||
public static let bottom: Separators = Separators(rawValue: 1 << 1)
|
||||
}
|
||||
|
||||
struct Padding: Equatable, Hashable {
|
||||
let top: CGFloat?
|
||||
let leading: CGFloat?
|
||||
let trailing: CGFloat?
|
||||
let bottom: CGFloat?
|
||||
let interItem: CGFloat?
|
||||
|
||||
init(
|
||||
top: CGFloat? = nil,
|
||||
leading: CGFloat? = nil,
|
||||
trailing: CGFloat? = nil,
|
||||
bottom: CGFloat? = nil,
|
||||
interItem: CGFloat? = nil
|
||||
) {
|
||||
self.top = top
|
||||
self.leading = leading
|
||||
self.trailing = trailing
|
||||
self.bottom = bottom
|
||||
self.interItem = interItem
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ExpressibleByStringLiteral
|
||||
|
||||
extension SessionCell.TextInfo: ExpressibleByStringLiteral, ExpressibleByExtendedGraphemeClusterLiteral, ExpressibleByUnicodeScalarLiteral {
|
||||
public init(stringLiteral value: String) {
|
||||
self = SessionCell.TextInfo(value, font: .title)
|
||||
}
|
||||
|
||||
public init(unicodeScalarLiteral value: Character) {
|
||||
self = SessionCell.TextInfo(String(value), font: .title)
|
||||
}
|
||||
}
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import Foundation
|
||||
import DifferenceKit
|
||||
import SessionUIKit
|
||||
|
||||
protocol SessionTableSection: Differentiable {
|
||||
var title: String? { get }
|
||||
|
@ -13,8 +14,36 @@ extension SessionTableSection {
|
|||
var style: SessionTableSectionStyle { .none }
|
||||
}
|
||||
|
||||
public enum SessionTableSectionStyle: Differentiable {
|
||||
public enum SessionTableSectionStyle: Equatable, Hashable, Differentiable {
|
||||
case none
|
||||
case title
|
||||
case titleRoundedContent
|
||||
case titleEdgeToEdgeContent
|
||||
case titleNoBackgroundContent
|
||||
case titleSeparator
|
||||
case padding
|
||||
case loadMore
|
||||
|
||||
var height: CGFloat {
|
||||
switch self {
|
||||
case .none: return 0
|
||||
case .titleRoundedContent, .titleEdgeToEdgeContent, .titleNoBackgroundContent:
|
||||
return UITableView.automaticDimension
|
||||
|
||||
case .titleSeparator: return Separator.height
|
||||
case .padding: return Values.smallSpacing
|
||||
case .loadMore: return 40
|
||||
}
|
||||
}
|
||||
|
||||
/// These values should always be consistent with the padding in `SessionCell` to ensure the text lines up
|
||||
var edgePadding: CGFloat {
|
||||
switch self {
|
||||
case .titleRoundedContent, .titleNoBackgroundContent:
|
||||
// Align to the start of the text in the cell
|
||||
return (Values.largeSpacing + Values.mediumSpacing)
|
||||
|
||||
case .titleEdgeToEdgeContent, .titleSeparator: return Values.largeSpacing
|
||||
case .none, .padding, .loadMore: return 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -68,15 +68,15 @@ final class UserSelectionVC: BaseVC, UITableViewDataSource, UITableViewDelegate
|
|||
cell.update(
|
||||
with: SessionCell.Info(
|
||||
id: profile,
|
||||
leftAccessory: .profile(profile.id, profile),
|
||||
position: Position.with(indexPath.row, count: users.count),
|
||||
leftAccessory: .profile(id: profile.id, profile: profile),
|
||||
title: profile.displayName(),
|
||||
rightAccessory: .radio(isSelected: { [weak self] in
|
||||
self?.selectedUsers.contains(profile.id) == true
|
||||
}),
|
||||
accessibilityIdentifier: "Contact"
|
||||
),
|
||||
style: .edgeToEdge,
|
||||
position: Position.with(indexPath.row, count: users.count)
|
||||
styling: SessionCell.StyleInfo(backgroundStyle: .edgeToEdge),
|
||||
accessibility: SessionCell.Accessibility(identifier: "Contact")
|
||||
)
|
||||
)
|
||||
|
||||
return cell
|
||||
|
|
|
@ -1,308 +0,0 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import Combine
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
import SessionUtilitiesKit
|
||||
import SignalUtilitiesKit
|
||||
|
||||
class SessionAvatarCell: UITableViewCell {
|
||||
var disposables: Set<AnyCancellable> = Set()
|
||||
private var originalInputValue: String?
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
|
||||
setupViewHierarchy()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
|
||||
setupViewHierarchy()
|
||||
}
|
||||
|
||||
// MARK: - UI
|
||||
|
||||
private let stackView: UIStackView = {
|
||||
let stackView: UIStackView = UIStackView()
|
||||
stackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
stackView.axis = .vertical
|
||||
stackView.spacing = Values.mediumSpacing
|
||||
stackView.alignment = .center
|
||||
stackView.distribution = .equalSpacing
|
||||
|
||||
let horizontalSpacing: CGFloat = (UIScreen.main.bounds.size.height < 568 ?
|
||||
Values.largeSpacing :
|
||||
Values.veryLargeSpacing
|
||||
)
|
||||
stackView.layoutMargins = UIEdgeInsets(
|
||||
top: Values.mediumSpacing,
|
||||
leading: horizontalSpacing,
|
||||
bottom: Values.largeSpacing,
|
||||
trailing: horizontalSpacing
|
||||
)
|
||||
stackView.isLayoutMarginsRelativeArrangement = true
|
||||
|
||||
return stackView
|
||||
}()
|
||||
|
||||
fileprivate let profilePictureView: ProfilePictureView = {
|
||||
let view: ProfilePictureView = ProfilePictureView()
|
||||
view.accessibilityLabel = "Profile picture"
|
||||
view.isAccessibilityElement = true
|
||||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.size = Values.largeProfilePictureSize
|
||||
|
||||
return view
|
||||
}()
|
||||
|
||||
fileprivate let displayNameContainer: UIView = {
|
||||
let view: UIView = UIView()
|
||||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.accessibilityLabel = "Username"
|
||||
view.isAccessibilityElement = true
|
||||
|
||||
return view
|
||||
}()
|
||||
|
||||
private lazy var displayNameLabel: UILabel = {
|
||||
let label: UILabel = UILabel()
|
||||
label.isAccessibilityElement = true
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
label.font = .ows_mediumFont(withSize: Values.veryLargeFontSize)
|
||||
label.themeTextColor = .textPrimary
|
||||
label.textAlignment = .center
|
||||
label.lineBreakMode = .byTruncatingTail
|
||||
label.numberOfLines = 0
|
||||
|
||||
return label
|
||||
}()
|
||||
|
||||
fileprivate let displayNameTextField: UITextField = {
|
||||
let textField: TextField = TextField(placeholder: "Enter a name", usesDefaultHeight: false)
|
||||
textField.translatesAutoresizingMaskIntoConstraints = false
|
||||
textField.textAlignment = .center
|
||||
textField.accessibilityIdentifier = "Nickname"
|
||||
textField.accessibilityLabel = "Nickname"
|
||||
textField.isAccessibilityElement = true
|
||||
textField.alpha = 0
|
||||
|
||||
return textField
|
||||
}()
|
||||
|
||||
private let descriptionSeparator: Separator = {
|
||||
let result: Separator = Separator()
|
||||
result.isHidden = true
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private let descriptionLabel: SRCopyableLabel = {
|
||||
let label: SRCopyableLabel = SRCopyableLabel()
|
||||
label.accessibilityLabel = "Session ID"
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
label.themeTextColor = .textPrimary
|
||||
label.textAlignment = .center
|
||||
label.lineBreakMode = .byCharWrapping
|
||||
label.numberOfLines = 0
|
||||
|
||||
return label
|
||||
}()
|
||||
|
||||
private let descriptionActionStackView: UIStackView = {
|
||||
let stackView: UIStackView = UIStackView()
|
||||
stackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
stackView.axis = .horizontal
|
||||
stackView.alignment = .center
|
||||
stackView.distribution = .fillEqually
|
||||
stackView.spacing = (UIDevice.current.isIPad ? Values.iPadButtonSpacing : Values.mediumSpacing)
|
||||
|
||||
return stackView
|
||||
}()
|
||||
|
||||
private func setupViewHierarchy() {
|
||||
self.themeBackgroundColor = nil
|
||||
self.selectedBackgroundView = UIView()
|
||||
|
||||
contentView.addSubview(stackView)
|
||||
|
||||
stackView.addArrangedSubview(profilePictureView)
|
||||
stackView.addArrangedSubview(displayNameContainer)
|
||||
stackView.addArrangedSubview(descriptionSeparator)
|
||||
stackView.addArrangedSubview(descriptionLabel)
|
||||
stackView.addArrangedSubview(descriptionActionStackView)
|
||||
|
||||
displayNameContainer.addSubview(displayNameLabel)
|
||||
displayNameContainer.addSubview(displayNameTextField)
|
||||
|
||||
setupLayout()
|
||||
}
|
||||
|
||||
// MARK: - Layout
|
||||
|
||||
private func setupLayout() {
|
||||
stackView.pin(to: contentView)
|
||||
|
||||
profilePictureView.set(.width, to: profilePictureView.size)
|
||||
profilePictureView.set(.height, to: profilePictureView.size)
|
||||
|
||||
displayNameLabel.pin(to: displayNameContainer)
|
||||
displayNameTextField.center(in: displayNameContainer)
|
||||
displayNameTextField.widthAnchor
|
||||
.constraint(
|
||||
lessThanOrEqualTo: stackView.widthAnchor,
|
||||
constant: -(stackView.layoutMargins.left + stackView.layoutMargins.right)
|
||||
)
|
||||
.isActive = true
|
||||
|
||||
descriptionSeparator.set(
|
||||
.width,
|
||||
to: .width,
|
||||
of: stackView,
|
||||
withOffset: -(stackView.layoutMargins.left + stackView.layoutMargins.right)
|
||||
)
|
||||
descriptionActionStackView.set(
|
||||
.width,
|
||||
to: .width,
|
||||
of: stackView,
|
||||
withOffset: -(stackView.layoutMargins.left + stackView.layoutMargins.right)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Content
|
||||
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
|
||||
self.disposables = Set()
|
||||
self.originalInputValue = nil
|
||||
self.displayNameLabel.text = nil
|
||||
self.displayNameTextField.text = nil
|
||||
self.descriptionLabel.font = .ows_lightFont(withSize: Values.smallFontSize)
|
||||
self.descriptionLabel.text = nil
|
||||
|
||||
self.descriptionSeparator.isHidden = true
|
||||
self.descriptionActionStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||
}
|
||||
|
||||
func update(
|
||||
threadViewModel: SessionThreadViewModel,
|
||||
style: SessionCell.Accessory.ThreadInfoStyle,
|
||||
viewController: UIViewController
|
||||
) {
|
||||
profilePictureView.update(
|
||||
publicKey: threadViewModel.threadId,
|
||||
profile: threadViewModel.profile,
|
||||
additionalProfile: threadViewModel.additionalProfile,
|
||||
threadVariant: threadViewModel.threadVariant,
|
||||
openGroupProfilePictureData: threadViewModel.openGroupProfilePictureData,
|
||||
useFallbackPicture: (
|
||||
threadViewModel.threadVariant == .openGroup &&
|
||||
threadViewModel.openGroupProfilePictureData == nil
|
||||
),
|
||||
showMultiAvatarForClosedGroup: true
|
||||
)
|
||||
|
||||
originalInputValue = threadViewModel.profile?.nickname
|
||||
displayNameLabel.text = {
|
||||
guard !threadViewModel.threadIsNoteToSelf else {
|
||||
guard let profile: Profile = threadViewModel.profile else {
|
||||
return Profile.truncated(id: threadViewModel.threadId, truncating: .middle)
|
||||
}
|
||||
|
||||
return profile.displayName()
|
||||
}
|
||||
|
||||
return threadViewModel.displayName
|
||||
}()
|
||||
descriptionLabel.font = {
|
||||
switch style.descriptionStyle {
|
||||
case .small: return .ows_lightFont(withSize: Values.smallFontSize)
|
||||
case .monoSmall: return Fonts.spaceMono(ofSize: Values.smallFontSize)
|
||||
case .monoLarge: return Fonts.spaceMono(
|
||||
ofSize: (isIPhone5OrSmaller ? Values.mediumFontSize : Values.largeFontSize)
|
||||
)
|
||||
}
|
||||
}()
|
||||
descriptionLabel.text = threadViewModel.threadId
|
||||
descriptionLabel.isHidden = (threadViewModel.threadVariant != .contact)
|
||||
descriptionLabel.isUserInteractionEnabled = (
|
||||
threadViewModel.threadVariant == .contact ||
|
||||
threadViewModel.threadVariant == .openGroup
|
||||
)
|
||||
displayNameTextField.text = threadViewModel.profile?.nickname
|
||||
descriptionSeparator.update(title: style.separatorTitle)
|
||||
descriptionSeparator.isHidden = (style.separatorTitle == nil)
|
||||
|
||||
if (UIDevice.current.isIPad) {
|
||||
descriptionActionStackView.addArrangedSubview(UIView.hStretchingSpacer())
|
||||
}
|
||||
|
||||
style.descriptionActions.forEach { action in
|
||||
let result: SessionButton = SessionButton(style: .bordered, size: .medium)
|
||||
result.setTitle(action.title, for: UIControl.State.normal)
|
||||
result.tapPublisher
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink(receiveValue: { [weak result] _ in action.run(result) })
|
||||
.store(in: &self.disposables)
|
||||
|
||||
descriptionActionStackView.addArrangedSubview(result)
|
||||
}
|
||||
|
||||
if (UIDevice.current.isIPad) {
|
||||
descriptionActionStackView.addArrangedSubview(UIView.hStretchingSpacer())
|
||||
}
|
||||
descriptionActionStackView.isHidden = style.descriptionActions.isEmpty
|
||||
}
|
||||
|
||||
func update(isEditing: Bool, animated: Bool) {
|
||||
let changes = { [weak self] in
|
||||
self?.displayNameLabel.alpha = (isEditing ? 0 : 1)
|
||||
self?.displayNameTextField.alpha = (isEditing ? 1 : 0)
|
||||
}
|
||||
let completion: (Bool) -> Void = { [weak self] complete in
|
||||
self?.displayNameTextField.text = self?.originalInputValue
|
||||
}
|
||||
|
||||
if animated {
|
||||
UIView.animate(withDuration: 0.25, animations: changes, completion: completion)
|
||||
}
|
||||
else {
|
||||
changes()
|
||||
completion(true)
|
||||
}
|
||||
|
||||
if isEditing {
|
||||
displayNameTextField.becomeFirstResponder()
|
||||
}
|
||||
else {
|
||||
displayNameTextField.resignFirstResponder()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Compose
|
||||
|
||||
extension CombineCompatible where Self: SessionAvatarCell {
|
||||
var textPublisher: AnyPublisher<String, Never> {
|
||||
return self.displayNameTextField.publisher(for: .editingChanged)
|
||||
.map { textField -> String in (textField.text ?? "") }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
var displayNameTapPublisher: AnyPublisher<Void, Never> {
|
||||
return self.displayNameContainer.tapPublisher
|
||||
.map { _ in () }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
var profilePictureTapPublisher: AnyPublisher<Void, Never> {
|
||||
return self.profilePictureView.tapPublisher
|
||||
.map { _ in () }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
|
@ -7,15 +7,25 @@ import SessionUtilitiesKit
|
|||
import SignalUtilitiesKit
|
||||
|
||||
extension SessionCell {
|
||||
public class AccessoryView: UIView {
|
||||
public class AccessoryView: UIView, UISearchBarDelegate {
|
||||
// Note: We set a minimum width for the 'AccessoryView' so that the titles line up
|
||||
// nicely when we have a mix of icons and switches
|
||||
private static let minWidth: CGFloat = 50
|
||||
|
||||
private var onTap: ((SessionButton?) -> Void)?
|
||||
private var searchTermChanged: ((String?) -> Void)?
|
||||
|
||||
// MARK: - UI
|
||||
|
||||
private lazy var minWidthConstraint: NSLayoutConstraint = self.widthAnchor
|
||||
.constraint(greaterThanOrEqualToConstant: AccessoryView.minWidth)
|
||||
private lazy var fixedWidthConstraint: NSLayoutConstraint = self.set(.width, to: AccessoryView.minWidth)
|
||||
private lazy var imageViewConstraints: [NSLayoutConstraint] = [
|
||||
imageView.pin(.top, to: .top, of: self),
|
||||
imageView.pin(.leading, to: .leading, of: self),
|
||||
imageView.pin(.trailing, to: .trailing, of: self),
|
||||
imageView.pin(.bottom, to: .bottom, of: self)
|
||||
]
|
||||
private lazy var imageViewLeadingConstraint: NSLayoutConstraint = imageView.pin(.leading, to: .leading, of: self)
|
||||
private lazy var imageViewTrailingConstraint: NSLayoutConstraint = imageView.pin(.trailing, to: .trailing, of: self)
|
||||
private lazy var imageViewWidthConstraint: NSLayoutConstraint = imageView.set(.width, to: 0)
|
||||
private lazy var imageViewHeightConstraint: NSLayoutConstraint = imageView.set(.height, to: 0)
|
||||
private lazy var toggleSwitchConstraints: [NSLayoutConstraint] = [
|
||||
|
@ -26,8 +36,8 @@ extension SessionCell {
|
|||
]
|
||||
private lazy var dropDownStackViewConstraints: [NSLayoutConstraint] = [
|
||||
dropDownStackView.pin(.top, to: .top, of: self),
|
||||
dropDownStackView.pin(.leading, to: .leading, of: self),
|
||||
dropDownStackView.pin(.trailing, to: .trailing, of: self),
|
||||
dropDownStackView.pin(.leading, to: .leading, of: self, withInset: Values.smallSpacing),
|
||||
dropDownStackView.pin(.trailing, to: .trailing, of: self, withInset: -Values.smallSpacing),
|
||||
dropDownStackView.pin(.bottom, to: .bottom, of: self)
|
||||
]
|
||||
private lazy var radioViewWidthConstraint: NSLayoutConstraint = radioView.set(.width, to: 0)
|
||||
|
@ -36,22 +46,35 @@ extension SessionCell {
|
|||
private lazy var radioBorderViewHeightConstraint: NSLayoutConstraint = radioBorderView.set(.height, to: 0)
|
||||
private lazy var radioBorderViewConstraints: [NSLayoutConstraint] = [
|
||||
radioBorderView.pin(.top, to: .top, of: self),
|
||||
radioBorderView.pin(.leading, to: .leading, of: self),
|
||||
radioBorderView.pin(.trailing, to: .trailing, of: self),
|
||||
radioBorderView.center(.horizontal, in: self),
|
||||
radioBorderView.pin(.bottom, to: .bottom, of: self)
|
||||
]
|
||||
private lazy var highlightingBackgroundLabelConstraints: [NSLayoutConstraint] = [
|
||||
highlightingBackgroundLabel.pin(.top, to: .top, of: self),
|
||||
highlightingBackgroundLabel.pin(.leading, to: .leading, of: self),
|
||||
highlightingBackgroundLabel.pin(.trailing, to: .trailing, of: self),
|
||||
highlightingBackgroundLabel.pin(.leading, to: .leading, of: self, withInset: Values.smallSpacing),
|
||||
highlightingBackgroundLabel.pin(.trailing, to: .trailing, of: self, withInset: -Values.smallSpacing),
|
||||
highlightingBackgroundLabel.pin(.bottom, to: .bottom, of: self)
|
||||
]
|
||||
private lazy var profilePictureViewLeadingConstraint: NSLayoutConstraint = profilePictureView.pin(.leading, to: .leading, of: self)
|
||||
private lazy var profilePictureViewTrailingConstraint: NSLayoutConstraint = profilePictureView.pin(.trailing, to: .trailing, of: self)
|
||||
private lazy var profilePictureViewConstraints: [NSLayoutConstraint] = [
|
||||
profilePictureView.pin(.top, to: .top, of: self),
|
||||
profilePictureView.pin(.leading, to: .leading, of: self),
|
||||
profilePictureView.pin(.trailing, to: .trailing, of: self),
|
||||
profilePictureView.pin(.bottom, to: .bottom, of: self)
|
||||
]
|
||||
private lazy var profilePictureViewWidthConstraint: NSLayoutConstraint = profilePictureView.set(.width, to: 0)
|
||||
private lazy var profilePictureViewHeightConstraint: NSLayoutConstraint = profilePictureView.set(.height, to: 0)
|
||||
private lazy var searchBarConstraints: [NSLayoutConstraint] = [
|
||||
searchBar.pin(.top, to: .top, of: self),
|
||||
searchBar.pin(.leading, to: .leading, of: self, withInset: -8), // Removing default inset
|
||||
searchBar.pin(.trailing, to: .trailing, of: self, withInset: 8), // Removing default inset
|
||||
searchBar.pin(.bottom, to: .bottom, of: self)
|
||||
]
|
||||
private lazy var buttonConstraints: [NSLayoutConstraint] = [
|
||||
button.pin(.top, to: .top, of: self),
|
||||
button.pin(.leading, to: .leading, of: self),
|
||||
button.pin(.trailing, to: .trailing, of: self),
|
||||
button.pin(.bottom, to: .bottom, of: self)
|
||||
]
|
||||
|
||||
private let imageView: UIImageView = {
|
||||
let result: UIImageView = UIImageView()
|
||||
|
@ -143,10 +166,45 @@ extension SessionCell {
|
|||
private lazy var profilePictureView: ProfilePictureView = {
|
||||
let result: ProfilePictureView = ProfilePictureView()
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.size = Values.smallProfilePictureSize
|
||||
result.isHidden = true
|
||||
result.set(.width, to: Values.smallProfilePictureSize)
|
||||
result.set(.height, to: Values.smallProfilePictureSize)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var profileIconContainerView: UIView = {
|
||||
let result: UIView = UIView()
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.themeBackgroundColor = .primary
|
||||
result.isHidden = true
|
||||
result.set(.width, to: 26)
|
||||
result.set(.height, to: 26)
|
||||
result.layer.cornerRadius = (26 / 2)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var profileIconImageView: UIImageView = {
|
||||
let result: UIImageView = UIImageView()
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var searchBar: UISearchBar = {
|
||||
let result: ContactsSearchBar = ContactsSearchBar()
|
||||
result.themeTintColor = .textPrimary
|
||||
result.themeBackgroundColor = .clear
|
||||
result.searchTextField.themeBackgroundColor = .backgroundSecondary
|
||||
result.delegate = self
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var button: SessionButton = {
|
||||
let result: SessionButton = SessionButton(style: .bordered, size: .medium)
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.addTarget(self, action: #selector(buttonTapped), for: .touchUpInside)
|
||||
result.isHidden = true
|
||||
|
||||
return result
|
||||
}()
|
||||
|
@ -174,18 +232,29 @@ extension SessionCell {
|
|||
addSubview(radioBorderView)
|
||||
addSubview(highlightingBackgroundLabel)
|
||||
addSubview(profilePictureView)
|
||||
addSubview(profileIconContainerView)
|
||||
addSubview(button)
|
||||
addSubview(searchBar)
|
||||
|
||||
dropDownStackView.addArrangedSubview(dropDownImageView)
|
||||
dropDownStackView.addArrangedSubview(dropDownLabel)
|
||||
|
||||
radioBorderView.addSubview(radioView)
|
||||
radioView.center(in: radioBorderView)
|
||||
|
||||
profileIconContainerView.addSubview(profileIconImageView)
|
||||
|
||||
profileIconContainerView.pin(.bottom, to: .bottom, of: profilePictureView)
|
||||
profileIconContainerView.pin(.trailing, to: .trailing, of: profilePictureView)
|
||||
profileIconImageView.pin(to: profileIconContainerView, withInset: Values.verySmallSpacing)
|
||||
}
|
||||
|
||||
// MARK: - Content
|
||||
|
||||
func prepareForReuse() {
|
||||
self.isHidden = true
|
||||
isHidden = true
|
||||
onTap = nil
|
||||
searchTermChanged = nil
|
||||
|
||||
imageView.image = nil
|
||||
imageView.themeTintColor = .textPrimary
|
||||
|
@ -207,7 +276,16 @@ extension SessionCell {
|
|||
radioView.isHidden = true
|
||||
highlightingBackgroundLabel.isHidden = true
|
||||
profilePictureView.isHidden = true
|
||||
profileIconContainerView.isHidden = true
|
||||
button.isHidden = true
|
||||
searchBar.isHidden = true
|
||||
|
||||
minWidthConstraint.constant = AccessoryView.minWidth
|
||||
minWidthConstraint.isActive = false
|
||||
fixedWidthConstraint.constant = AccessoryView.minWidth
|
||||
fixedWidthConstraint.isActive = false
|
||||
imageViewLeadingConstraint.isActive = false
|
||||
imageViewTrailingConstraint.isActive = false
|
||||
imageViewWidthConstraint.isActive = false
|
||||
imageViewHeightConstraint.isActive = false
|
||||
imageViewConstraints.forEach { $0.isActive = false }
|
||||
|
@ -219,14 +297,19 @@ extension SessionCell {
|
|||
radioBorderViewHeightConstraint.isActive = false
|
||||
radioBorderViewConstraints.forEach { $0.isActive = false }
|
||||
highlightingBackgroundLabelConstraints.forEach { $0.isActive = false }
|
||||
profilePictureViewLeadingConstraint.isActive = false
|
||||
profilePictureViewTrailingConstraint.isActive = false
|
||||
profilePictureViewWidthConstraint.isActive = false
|
||||
profilePictureViewHeightConstraint.isActive = false
|
||||
profilePictureViewConstraints.forEach { $0.isActive = false }
|
||||
searchBarConstraints.forEach { $0.isActive = false }
|
||||
buttonConstraints.forEach { $0.isActive = false }
|
||||
}
|
||||
|
||||
public func update(
|
||||
with accessory: Accessory?,
|
||||
tintColor: ThemeValue,
|
||||
isEnabled: Bool,
|
||||
accessibilityLabel: String?
|
||||
isEnabled: Bool
|
||||
) {
|
||||
guard let accessory: Accessory = accessory else { return }
|
||||
|
||||
|
@ -234,8 +317,9 @@ extension SessionCell {
|
|||
self.isHidden = false
|
||||
|
||||
switch accessory {
|
||||
case .icon(let image, let iconSize, let customTint, let shouldFill):
|
||||
imageView.accessibilityLabel = accessibilityLabel
|
||||
case .icon(let image, let iconSize, let customTint, let shouldFill, let accessibility):
|
||||
imageView.accessibilityIdentifier = accessibility?.identifier
|
||||
imageView.accessibilityLabel = accessibility?.label
|
||||
imageView.image = image
|
||||
imageView.themeTintColor = (customTint ?? tintColor)
|
||||
imageView.contentMode = (shouldFill ? .scaleAspectFill : .scaleAspectFit)
|
||||
|
@ -244,21 +328,30 @@ extension SessionCell {
|
|||
switch iconSize {
|
||||
case .fit:
|
||||
imageView.sizeToFit()
|
||||
fixedWidthConstraint.constant = (imageView.bounds.width + (shouldFill ? 0 : (Values.smallSpacing * 2)))
|
||||
fixedWidthConstraint.isActive = true
|
||||
imageViewWidthConstraint.constant = imageView.bounds.width
|
||||
imageViewHeightConstraint.constant = imageView.bounds.height
|
||||
|
||||
default:
|
||||
fixedWidthConstraint.isActive = (iconSize.size <= fixedWidthConstraint.constant)
|
||||
imageViewWidthConstraint.constant = iconSize.size
|
||||
imageViewHeightConstraint.constant = iconSize.size
|
||||
}
|
||||
|
||||
minWidthConstraint.isActive = !fixedWidthConstraint.isActive
|
||||
imageViewLeadingConstraint.constant = (shouldFill ? 0 : Values.smallSpacing)
|
||||
imageViewTrailingConstraint.constant = (shouldFill ? 0 : -Values.smallSpacing)
|
||||
imageViewLeadingConstraint.isActive = true
|
||||
imageViewTrailingConstraint.isActive = true
|
||||
imageViewWidthConstraint.isActive = true
|
||||
imageViewHeightConstraint.isActive = true
|
||||
imageViewConstraints.forEach { $0.isActive = true }
|
||||
|
||||
case .iconAsync(let iconSize, let customTint, let shouldFill, let setter):
|
||||
case .iconAsync(let iconSize, let customTint, let shouldFill, let accessibility, let setter):
|
||||
setter(imageView)
|
||||
imageView.accessibilityLabel = accessibilityLabel
|
||||
imageView.accessibilityIdentifier = accessibility?.identifier
|
||||
imageView.accessibilityLabel = accessibility?.label
|
||||
imageView.themeTintColor = (customTint ?? tintColor)
|
||||
imageView.contentMode = (shouldFill ? .scaleAspectFill : .scaleAspectFit)
|
||||
imageView.isHidden = false
|
||||
|
@ -266,22 +359,33 @@ extension SessionCell {
|
|||
switch iconSize {
|
||||
case .fit:
|
||||
imageView.sizeToFit()
|
||||
fixedWidthConstraint.constant = (imageView.bounds.width + (shouldFill ? 0 : (Values.smallSpacing * 2)))
|
||||
fixedWidthConstraint.isActive = true
|
||||
imageViewWidthConstraint.constant = imageView.bounds.width
|
||||
imageViewHeightConstraint.constant = imageView.bounds.height
|
||||
|
||||
default:
|
||||
fixedWidthConstraint.isActive = (iconSize.size <= fixedWidthConstraint.constant)
|
||||
imageViewWidthConstraint.constant = iconSize.size
|
||||
imageViewHeightConstraint.constant = iconSize.size
|
||||
}
|
||||
|
||||
minWidthConstraint.isActive = !fixedWidthConstraint.isActive
|
||||
imageViewLeadingConstraint.constant = (shouldFill ? 0 : Values.smallSpacing)
|
||||
imageViewTrailingConstraint.constant = (shouldFill ? 0 : -Values.smallSpacing)
|
||||
imageViewLeadingConstraint.isActive = true
|
||||
imageViewTrailingConstraint.isActive = true
|
||||
imageViewWidthConstraint.isActive = true
|
||||
imageViewHeightConstraint.isActive = true
|
||||
imageViewConstraints.forEach { $0.isActive = true }
|
||||
|
||||
case .toggle(let dataSource):
|
||||
toggleSwitch.accessibilityLabel = accessibilityLabel
|
||||
case .toggle(let dataSource, let accessibility):
|
||||
toggleSwitch.accessibilityIdentifier = accessibility?.identifier
|
||||
toggleSwitch.accessibilityLabel = accessibility?.label
|
||||
toggleSwitch.isHidden = false
|
||||
toggleSwitch.isEnabled = isEnabled
|
||||
|
||||
fixedWidthConstraint.isActive = true
|
||||
toggleSwitchConstraints.forEach { $0.isActive = true }
|
||||
|
||||
let newValue: Bool = dataSource.currentBoolValue
|
||||
|
@ -290,13 +394,15 @@ extension SessionCell {
|
|||
toggleSwitch.setOn(newValue, animated: true)
|
||||
}
|
||||
|
||||
case .dropDown(let dataSource):
|
||||
dropDownLabel.accessibilityLabel = accessibilityLabel
|
||||
case .dropDown(let dataSource, let accessibility):
|
||||
dropDownLabel.accessibilityIdentifier = accessibility?.identifier
|
||||
dropDownLabel.accessibilityLabel = accessibility?.label
|
||||
dropDownLabel.text = dataSource.currentStringValue
|
||||
dropDownStackView.isHidden = false
|
||||
dropDownStackViewConstraints.forEach { $0.isActive = true }
|
||||
minWidthConstraint.isActive = true
|
||||
|
||||
case .radio(let size, let isSelectedRetriever, let storedSelection):
|
||||
case .radio(let size, let isSelectedRetriever, let storedSelection, let accessibility):
|
||||
let isSelected: Bool = isSelectedRetriever()
|
||||
let wasOldSelection: Bool = (!isSelected && storedSelection)
|
||||
|
||||
|
@ -307,7 +413,8 @@ extension SessionCell {
|
|||
)
|
||||
radioBorderView.layer.cornerRadius = (size.borderSize / 2)
|
||||
|
||||
radioView.accessibilityLabel = accessibilityLabel
|
||||
radioView.accessibilityIdentifier = accessibility?.identifier
|
||||
radioView.accessibilityLabel = accessibility?.label
|
||||
radioView.alpha = (wasOldSelection ? 0.3 : 1)
|
||||
radioView.isHidden = (!isSelected && !storedSelection)
|
||||
radioView.themeBackgroundColor = (isSelected || wasOldSelection ?
|
||||
|
@ -321,32 +428,89 @@ extension SessionCell {
|
|||
radioBorderViewWidthConstraint.constant = size.borderSize
|
||||
radioBorderViewHeightConstraint.constant = size.borderSize
|
||||
|
||||
fixedWidthConstraint.isActive = true
|
||||
radioViewWidthConstraint.isActive = true
|
||||
radioViewHeightConstraint.isActive = true
|
||||
radioBorderViewWidthConstraint.isActive = true
|
||||
radioBorderViewHeightConstraint.isActive = true
|
||||
radioBorderViewConstraints.forEach { $0.isActive = true }
|
||||
|
||||
case .highlightingBackgroundLabel(let title):
|
||||
highlightingBackgroundLabel.accessibilityLabel = accessibilityLabel
|
||||
case .highlightingBackgroundLabel(let title, let accessibility):
|
||||
highlightingBackgroundLabel.accessibilityIdentifier = accessibility?.identifier
|
||||
highlightingBackgroundLabel.accessibilityLabel = accessibility?.label
|
||||
highlightingBackgroundLabel.text = title
|
||||
highlightingBackgroundLabel.themeTextColor = tintColor
|
||||
highlightingBackgroundLabel.isHidden = false
|
||||
highlightingBackgroundLabelConstraints.forEach { $0.isActive = true }
|
||||
minWidthConstraint.isActive = true
|
||||
|
||||
case .profile(let profileId, let profile):
|
||||
profilePictureView.accessibilityLabel = accessibilityLabel
|
||||
case .profile(
|
||||
let profileId,
|
||||
let profileSize,
|
||||
let threadVariant,
|
||||
let customImageData,
|
||||
let profile,
|
||||
let additionalProfile,
|
||||
let cornerIcon,
|
||||
let accessibility
|
||||
):
|
||||
// Note: We MUST set the 'size' property before triggering the 'update'
|
||||
// function or the profile picture won't layout correctly
|
||||
switch profileSize {
|
||||
case .fit:
|
||||
profilePictureView.size = IconSize.large.size
|
||||
profilePictureViewWidthConstraint.constant = IconSize.large.size
|
||||
profilePictureViewHeightConstraint.constant = IconSize.large.size
|
||||
|
||||
default:
|
||||
profilePictureView.size = profileSize.size
|
||||
profilePictureViewWidthConstraint.constant = profileSize.size
|
||||
profilePictureViewHeightConstraint.constant = profileSize.size
|
||||
}
|
||||
|
||||
profilePictureView.accessibilityIdentifier = accessibility?.identifier
|
||||
profilePictureView.accessibilityLabel = accessibility?.label
|
||||
profilePictureView.update(
|
||||
publicKey: profileId,
|
||||
threadVariant: threadVariant,
|
||||
customImageData: customImageData,
|
||||
profile: profile,
|
||||
threadVariant: .contact
|
||||
additionalProfile: additionalProfile
|
||||
)
|
||||
profilePictureView.isHidden = false
|
||||
profileIconContainerView.isHidden = (cornerIcon == nil)
|
||||
profileIconImageView.image = cornerIcon
|
||||
|
||||
fixedWidthConstraint.constant = profilePictureViewWidthConstraint.constant
|
||||
fixedWidthConstraint.isActive = true
|
||||
profilePictureViewLeadingConstraint.constant = (profilePictureView.size > AccessoryView.minWidth ? 0 : Values.smallSpacing)
|
||||
profilePictureViewTrailingConstraint.constant = (profilePictureView.size > AccessoryView.minWidth ? 0 : -Values.smallSpacing)
|
||||
profilePictureViewLeadingConstraint.isActive = true
|
||||
profilePictureViewTrailingConstraint.isActive = true
|
||||
profilePictureViewWidthConstraint.isActive = true
|
||||
profilePictureViewHeightConstraint.isActive = true
|
||||
profilePictureViewConstraints.forEach { $0.isActive = true }
|
||||
|
||||
case .customView(let viewGenerator):
|
||||
case .search(let placeholder, let accessibility, let searchTermChanged):
|
||||
self.searchTermChanged = searchTermChanged
|
||||
searchBar.accessibilityIdentifier = accessibility?.identifier
|
||||
searchBar.accessibilityLabel = accessibility?.label
|
||||
searchBar.placeholder = placeholder
|
||||
searchBar.isHidden = false
|
||||
searchBarConstraints.forEach { $0.isActive = true }
|
||||
|
||||
case .button(let style, let title, let accessibility, let onTap):
|
||||
self.onTap = onTap
|
||||
button.accessibilityIdentifier = accessibility?.identifier
|
||||
button.accessibilityLabel = accessibility?.label
|
||||
button.setTitle(title, for: .normal)
|
||||
button.setStyle(style)
|
||||
button.isHidden = false
|
||||
minWidthConstraint.isActive = true
|
||||
buttonConstraints.forEach { $0.isActive = true }
|
||||
|
||||
case .customView(_, let viewGenerator):
|
||||
let generatedView: UIView = viewGenerator()
|
||||
generatedView.accessibilityLabel = accessibilityLabel
|
||||
addSubview(generatedView)
|
||||
|
||||
generatedView.pin(.top, to: .top, of: self)
|
||||
|
@ -354,10 +518,9 @@ extension SessionCell {
|
|||
generatedView.pin(.trailing, to: .trailing, of: self)
|
||||
generatedView.pin(.bottom, to: .bottom, of: self)
|
||||
|
||||
self.customView?.removeFromSuperview() // Just in case
|
||||
self.customView = generatedView
|
||||
|
||||
case .threadInfo: break
|
||||
customView?.removeFromSuperview() // Just in case
|
||||
customView = generatedView
|
||||
minWidthConstraint.isActive = true
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -370,6 +533,27 @@ extension SessionCell {
|
|||
func setSelected(_ selected: Bool, animated: Bool) {
|
||||
highlightingBackgroundLabel.setSelected(selected, animated: animated)
|
||||
}
|
||||
|
||||
@objc private func buttonTapped() {
|
||||
onTap?(button)
|
||||
}
|
||||
|
||||
// MARK: - UISearchBarDelegate
|
||||
|
||||
public func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
|
||||
searchTermChanged?(searchText)
|
||||
}
|
||||
|
||||
public func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) {
|
||||
searchBar.setShowsCancelButton(true, animated: true)
|
||||
}
|
||||
|
||||
public func searchBarTextDidEndEditing(_ searchBar: UISearchBar) {
|
||||
searchBar.setShowsCancelButton(false, animated: true)
|
||||
}
|
||||
|
||||
public func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
|
||||
searchBar.endEditing(true)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import Combine
|
||||
import GRDB
|
||||
import DifferenceKit
|
||||
import SessionUIKit
|
||||
|
@ -9,17 +10,13 @@ import SessionUtilitiesKit
|
|||
public class SessionCell: UITableViewCell {
|
||||
public static let cornerRadius: CGFloat = 17
|
||||
|
||||
public enum Style {
|
||||
case rounded
|
||||
case roundedEdgeToEdge
|
||||
case edgeToEdge
|
||||
}
|
||||
|
||||
/// This value is here to allow the theming update callback to be released when preparing for reuse
|
||||
private var instanceView: UIView = UIView()
|
||||
private var position: Position?
|
||||
private var isEditingTitle = false
|
||||
public private(set) var interactionMode: SessionCell.TextInfo.Interaction = .none
|
||||
private var shouldHighlightTitle: Bool = true
|
||||
private var originalInputValue: String?
|
||||
private var titleExtraView: UIView?
|
||||
private var subtitleExtraView: UIView?
|
||||
private var onExtraActionTap: (() -> Void)?
|
||||
var disposables: Set<AnyCancellable> = Set()
|
||||
|
||||
// MARK: - UI
|
||||
|
||||
|
@ -29,8 +26,18 @@ public class SessionCell: UITableViewCell {
|
|||
private var topSeparatorRightConstraint: NSLayoutConstraint = NSLayoutConstraint()
|
||||
private var botSeparatorLeftConstraint: NSLayoutConstraint = NSLayoutConstraint()
|
||||
private var botSeparatorRightConstraint: NSLayoutConstraint = NSLayoutConstraint()
|
||||
private lazy var contentStackViewTopConstraint: NSLayoutConstraint = contentStackView.pin(.top, to: .top, of: cellBackgroundView)
|
||||
private lazy var contentStackViewLeadingConstraint: NSLayoutConstraint = contentStackView.pin(.leading, to: .leading, of: cellBackgroundView)
|
||||
private lazy var contentStackViewTrailingConstraint: NSLayoutConstraint = contentStackView.pin(.trailing, to: .trailing, of: cellBackgroundView)
|
||||
private lazy var contentStackViewBottomConstraint: NSLayoutConstraint = contentStackView.pin(.bottom, to: .bottom, of: cellBackgroundView)
|
||||
private lazy var contentStackViewHorizontalCenterConstraint: NSLayoutConstraint = contentStackView.center(.horizontal, in: cellBackgroundView)
|
||||
private lazy var leftAccessoryFillConstraint: NSLayoutConstraint = contentStackView.set(.height, to: .height, of: leftAccessoryView)
|
||||
private lazy var titleTextFieldLeadingConstraint: NSLayoutConstraint = titleTextField.pin(.leading, to: .leading, of: cellBackgroundView)
|
||||
private lazy var titleTextFieldTrailingConstraint: NSLayoutConstraint = titleTextField.pin(.trailing, to: .trailing, of: cellBackgroundView)
|
||||
private lazy var titleMinHeightConstraint: NSLayoutConstraint = titleStackView.heightAnchor
|
||||
.constraint(greaterThanOrEqualTo: titleTextField.heightAnchor)
|
||||
private lazy var rightAccessoryFillConstraint: NSLayoutConstraint = contentStackView.set(.height, to: .height, of: rightAccessoryView)
|
||||
private lazy var accessoryWidthMatchConstraint: NSLayoutConstraint = leftAccessoryView.set(.width, to: .width, of: rightAccessoryView)
|
||||
|
||||
private let cellBackgroundView: UIView = {
|
||||
let result: UIView = UIView()
|
||||
|
@ -65,7 +72,6 @@ public class SessionCell: UITableViewCell {
|
|||
result.distribution = .fill
|
||||
result.alignment = .center
|
||||
result.spacing = Values.mediumSpacing
|
||||
result.isLayoutMarginsRelativeArrangement = true
|
||||
|
||||
return result
|
||||
}()
|
||||
|
@ -89,10 +95,10 @@ public class SessionCell: UITableViewCell {
|
|||
return result
|
||||
}()
|
||||
|
||||
private let titleLabel: UILabel = {
|
||||
let result: UILabel = UILabel()
|
||||
private let titleLabel: SRCopyableLabel = {
|
||||
let result: SRCopyableLabel = SRCopyableLabel()
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.font = .boldSystemFont(ofSize: 15)
|
||||
result.isUserInteractionEnabled = false
|
||||
result.themeTextColor = .textPrimary
|
||||
result.numberOfLines = 0
|
||||
result.setCompressionResistanceHorizontalLow()
|
||||
|
@ -101,10 +107,21 @@ public class SessionCell: UITableViewCell {
|
|||
return result
|
||||
}()
|
||||
|
||||
private let subtitleLabel: UILabel = {
|
||||
let result: UILabel = UILabel()
|
||||
fileprivate let titleTextField: UITextField = {
|
||||
let textField: TextField = TextField(placeholder: "", usesDefaultHeight: false)
|
||||
textField.translatesAutoresizingMaskIntoConstraints = false
|
||||
textField.textAlignment = .center
|
||||
textField.alpha = 0
|
||||
textField.isHidden = true
|
||||
textField.set(.height, to: Values.largeButtonHeight)
|
||||
|
||||
return textField
|
||||
}()
|
||||
|
||||
private let subtitleLabel: SRCopyableLabel = {
|
||||
let result: SRCopyableLabel = SRCopyableLabel()
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.font = .systemFont(ofSize: 13)
|
||||
result.isUserInteractionEnabled = false
|
||||
result.themeTextColor = .textPrimary
|
||||
result.numberOfLines = 0
|
||||
result.isHidden = true
|
||||
|
@ -114,33 +131,6 @@ public class SessionCell: UITableViewCell {
|
|||
return result
|
||||
}()
|
||||
|
||||
private lazy var extraActionTopSpacingView: UIView = UIView.spacer(withHeight: Values.smallSpacing)
|
||||
|
||||
private lazy var extraActionButton: UIButton = {
|
||||
let result: UIButton = UIButton()
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.titleLabel?.font = .boldSystemFont(ofSize: Values.smallFontSize)
|
||||
result.titleLabel?.numberOfLines = 0
|
||||
result.contentHorizontalAlignment = .left
|
||||
result.contentEdgeInsets = UIEdgeInsets(
|
||||
top: 8,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
right: 0
|
||||
)
|
||||
result.addTarget(self, action: #selector(extraActionTapped), for: .touchUpInside)
|
||||
result.isHidden = true
|
||||
|
||||
ThemeManager.onThemeChange(observer: result) { [weak result] theme, _ in
|
||||
switch theme.interfaceStyle {
|
||||
case .light: result?.setThemeTitleColor(.textPrimary, for: .normal)
|
||||
default: result?.setThemeTitleColor(.primary, for: .normal)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
public let rightAccessoryView: AccessoryView = {
|
||||
let result: AccessoryView = AccessoryView()
|
||||
result.isHidden = true
|
||||
|
@ -186,8 +176,8 @@ public class SessionCell: UITableViewCell {
|
|||
|
||||
titleStackView.addArrangedSubview(titleLabel)
|
||||
titleStackView.addArrangedSubview(subtitleLabel)
|
||||
titleStackView.addArrangedSubview(extraActionTopSpacingView)
|
||||
titleStackView.addArrangedSubview(extraActionButton)
|
||||
|
||||
cellBackgroundView.addSubview(titleTextField)
|
||||
|
||||
setupLayout()
|
||||
}
|
||||
|
@ -204,7 +194,10 @@ public class SessionCell: UITableViewCell {
|
|||
topSeparatorLeftConstraint = topSeparator.pin(.left, to: .left, of: cellBackgroundView)
|
||||
topSeparatorRightConstraint = topSeparator.pin(.right, to: .right, of: cellBackgroundView)
|
||||
|
||||
contentStackView.pin(to: cellBackgroundView)
|
||||
contentStackViewTopConstraint.isActive = true
|
||||
contentStackViewBottomConstraint.isActive = true
|
||||
|
||||
titleTextField.center(.vertical, in: titleLabel)
|
||||
|
||||
botSeparatorLeftConstraint = botSeparator.pin(.left, to: .left, of: cellBackgroundView)
|
||||
botSeparatorRightConstraint = botSeparator.pin(.right, to: .right, of: cellBackgroundView)
|
||||
|
@ -217,55 +210,59 @@ public class SessionCell: UITableViewCell {
|
|||
// Need to force the contentStackView to layout if needed as it might not have updated it's
|
||||
// sizing yet
|
||||
self.contentStackView.layoutIfNeeded()
|
||||
repositionExtraView(titleExtraView, for: titleLabel)
|
||||
repositionExtraView(subtitleExtraView, for: subtitleLabel)
|
||||
}
|
||||
|
||||
private func repositionExtraView(_ targetView: UIView?, for label: UILabel) {
|
||||
guard
|
||||
let targetView: UIView = targetView,
|
||||
let content: String = label.text,
|
||||
let font: UIFont = label.font
|
||||
else { return }
|
||||
|
||||
// Position the 'subtitleExtraView' at the end of the last line of text
|
||||
if
|
||||
let subtitleExtraView: UIView = self.subtitleExtraView,
|
||||
let subtitle: String = subtitleLabel.text,
|
||||
let font: UIFont = subtitleLabel.font
|
||||
{
|
||||
let layoutManager: NSLayoutManager = NSLayoutManager()
|
||||
let textStorage = NSTextStorage(
|
||||
attributedString: NSAttributedString(
|
||||
string: subtitle,
|
||||
attributes: [ .font: font ]
|
||||
)
|
||||
// Position the 'targetView' at the end of the last line of text
|
||||
let layoutManager: NSLayoutManager = NSLayoutManager()
|
||||
let textStorage = NSTextStorage(
|
||||
attributedString: NSAttributedString(
|
||||
string: content,
|
||||
attributes: [ .font: font ]
|
||||
)
|
||||
textStorage.addLayoutManager(layoutManager)
|
||||
|
||||
let textContainer: NSTextContainer = NSTextContainer(
|
||||
size: CGSize(
|
||||
width: subtitleLabel.bounds.size.width,
|
||||
height: 999
|
||||
)
|
||||
)
|
||||
textStorage.addLayoutManager(layoutManager)
|
||||
|
||||
let textContainer: NSTextContainer = NSTextContainer(
|
||||
size: CGSize(
|
||||
width: label.bounds.size.width,
|
||||
height: 999
|
||||
)
|
||||
textContainer.lineFragmentPadding = 0
|
||||
layoutManager.addTextContainer(textContainer)
|
||||
|
||||
var glyphRange: NSRange = NSRange()
|
||||
layoutManager.characterRange(
|
||||
forGlyphRange: NSRange(location: subtitle.glyphCount - 1, length: 1),
|
||||
actualGlyphRange: &glyphRange
|
||||
)
|
||||
let lastGlyphRect: CGRect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer)
|
||||
|
||||
// Remove and re-add the 'subtitleExtraView' to clear any old constraints
|
||||
subtitleExtraView.removeFromSuperview()
|
||||
contentView.addSubview(subtitleExtraView)
|
||||
|
||||
subtitleExtraView.pin(
|
||||
.top,
|
||||
to: .top,
|
||||
of: subtitleLabel,
|
||||
withInset: (lastGlyphRect.minY + ((lastGlyphRect.height / 2) - (subtitleExtraView.bounds.height / 2)))
|
||||
)
|
||||
subtitleExtraView.pin(
|
||||
.leading,
|
||||
to: .leading,
|
||||
of: subtitleLabel,
|
||||
withInset: lastGlyphRect.maxX
|
||||
)
|
||||
}
|
||||
)
|
||||
textContainer.lineFragmentPadding = 0
|
||||
layoutManager.addTextContainer(textContainer)
|
||||
|
||||
var glyphRange: NSRange = NSRange()
|
||||
layoutManager.characterRange(
|
||||
forGlyphRange: NSRange(location: content.glyphCount - 1, length: 1),
|
||||
actualGlyphRange: &glyphRange
|
||||
)
|
||||
let lastGlyphRect: CGRect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer)
|
||||
|
||||
// Remove and re-add the 'subtitleExtraView' to clear any old constraints
|
||||
targetView.removeFromSuperview()
|
||||
contentView.addSubview(targetView)
|
||||
|
||||
targetView.pin(
|
||||
.top,
|
||||
to: .top,
|
||||
of: label,
|
||||
withInset: (lastGlyphRect.minY + ((lastGlyphRect.height / 2) - (targetView.bounds.height / 2)))
|
||||
)
|
||||
targetView.pin(
|
||||
.leading,
|
||||
to: .leading,
|
||||
of: label,
|
||||
withInset: lastGlyphRect.maxX
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Content
|
||||
|
@ -273,108 +270,185 @@ public class SessionCell: UITableViewCell {
|
|||
public override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
|
||||
self.instanceView = UIView()
|
||||
self.position = nil
|
||||
self.onExtraActionTap = nil
|
||||
self.accessibilityIdentifier = nil
|
||||
isEditingTitle = false
|
||||
interactionMode = .none
|
||||
shouldHighlightTitle = true
|
||||
accessibilityIdentifier = nil
|
||||
accessibilityLabel = nil
|
||||
originalInputValue = nil
|
||||
titleExtraView?.removeFromSuperview()
|
||||
titleExtraView = nil
|
||||
subtitleExtraView?.removeFromSuperview()
|
||||
subtitleExtraView = nil
|
||||
disposables = Set()
|
||||
|
||||
contentStackView.spacing = Values.mediumSpacing
|
||||
contentStackViewLeadingConstraint.isActive = false
|
||||
contentStackViewTrailingConstraint.isActive = false
|
||||
contentStackViewHorizontalCenterConstraint.isActive = false
|
||||
titleMinHeightConstraint.isActive = false
|
||||
leftAccessoryView.prepareForReuse()
|
||||
leftAccessoryView.alpha = 1
|
||||
leftAccessoryFillConstraint.isActive = false
|
||||
titleLabel.text = ""
|
||||
titleLabel.textAlignment = .left
|
||||
titleLabel.themeTextColor = .textPrimary
|
||||
titleLabel.alpha = 1
|
||||
titleTextField.text = ""
|
||||
titleTextField.textAlignment = .center
|
||||
titleTextField.themeTextColor = .textPrimary
|
||||
titleTextField.isHidden = true
|
||||
titleTextField.alpha = 0
|
||||
subtitleLabel.isUserInteractionEnabled = false
|
||||
subtitleLabel.text = ""
|
||||
subtitleLabel.themeTextColor = .textPrimary
|
||||
rightAccessoryView.prepareForReuse()
|
||||
rightAccessoryView.alpha = 1
|
||||
rightAccessoryFillConstraint.isActive = false
|
||||
accessoryWidthMatchConstraint.isActive = false
|
||||
|
||||
topSeparator.isHidden = true
|
||||
subtitleLabel.isHidden = true
|
||||
extraActionTopSpacingView.isHidden = true
|
||||
extraActionButton.setTitle("", for: .normal)
|
||||
extraActionButton.isHidden = true
|
||||
botSeparator.isHidden = true
|
||||
|
||||
subtitleExtraView?.removeFromSuperview()
|
||||
subtitleExtraView = nil
|
||||
}
|
||||
|
||||
public func update<ID: Hashable & Differentiable>(
|
||||
with info: Info<ID>,
|
||||
style: Style,
|
||||
position: Position
|
||||
) {
|
||||
self.instanceView = UIView()
|
||||
self.position = position
|
||||
self.subtitleExtraView = info.subtitleExtraViewGenerator?()
|
||||
self.onExtraActionTap = info.extraAction?.onTap
|
||||
self.accessibilityIdentifier = info.accessibilityIdentifier
|
||||
self.accessibilityLabel = info.accessibilityLabel
|
||||
self.isAccessibilityElement = true
|
||||
public func update<ID: Hashable & Differentiable>(with info: Info<ID>) {
|
||||
interactionMode = (info.title?.interaction ?? .none)
|
||||
shouldHighlightTitle = (info.title?.interaction != .copy)
|
||||
titleExtraView = info.title?.extraViewGenerator?()
|
||||
subtitleExtraView = info.subtitle?.extraViewGenerator?()
|
||||
accessibilityIdentifier = info.accessibility?.identifier
|
||||
accessibilityLabel = info.accessibility?.label
|
||||
originalInputValue = info.title?.text
|
||||
|
||||
// Convenience Flags
|
||||
let leftFitToEdge: Bool = (info.leftAccessory?.shouldFitToEdge == true)
|
||||
let rightFitToEdge: Bool = (!leftFitToEdge && info.rightAccessory?.shouldFitToEdge == true)
|
||||
leftAccessoryFillConstraint.isActive = leftFitToEdge
|
||||
|
||||
// Content
|
||||
contentStackView.spacing = (info.styling.customPadding?.interItem ?? Values.mediumSpacing)
|
||||
leftAccessoryView.update(
|
||||
with: info.leftAccessory,
|
||||
tintColor: info.tintColor,
|
||||
isEnabled: info.isEnabled,
|
||||
accessibilityLabel: info.leftAccessoryAccessibilityLabel
|
||||
tintColor: info.styling.tintColor,
|
||||
isEnabled: info.isEnabled
|
||||
)
|
||||
titleStackView.isHidden = (info.title == nil && info.subtitle == nil)
|
||||
titleLabel.isUserInteractionEnabled = (info.title?.interaction == .copy)
|
||||
titleLabel.font = info.title?.font
|
||||
titleLabel.text = info.title?.text
|
||||
titleLabel.themeTextColor = info.styling.tintColor
|
||||
titleLabel.textAlignment = (info.title?.textAlignment ?? .left)
|
||||
titleLabel.isHidden = (info.title == nil)
|
||||
titleTextField.text = info.title?.text
|
||||
titleTextField.textAlignment = (info.title?.textAlignment ?? .left)
|
||||
titleTextField.placeholder = info.title?.editingPlaceholder
|
||||
titleTextField.isHidden = (info.title == nil)
|
||||
subtitleLabel.isUserInteractionEnabled = (info.subtitle?.interaction == .copy)
|
||||
subtitleLabel.font = info.subtitle?.font
|
||||
subtitleLabel.text = info.subtitle?.text
|
||||
subtitleLabel.themeTextColor = info.styling.tintColor
|
||||
subtitleLabel.textAlignment = (info.subtitle?.textAlignment ?? .left)
|
||||
subtitleLabel.isHidden = (info.subtitle == nil)
|
||||
rightAccessoryView.update(
|
||||
with: info.rightAccessory,
|
||||
tintColor: info.tintColor,
|
||||
isEnabled: info.isEnabled,
|
||||
accessibilityLabel: info.rightAccessoryAccessibilityLabel
|
||||
)
|
||||
rightAccessoryFillConstraint.isActive = rightFitToEdge
|
||||
contentStackView.layoutMargins = UIEdgeInsets(
|
||||
top: (leftFitToEdge || rightFitToEdge ? 0 : Values.mediumSpacing),
|
||||
left: (leftFitToEdge ? 0 : Values.largeSpacing),
|
||||
bottom: (leftFitToEdge || rightFitToEdge ? 0 : Values.mediumSpacing),
|
||||
right: (rightFitToEdge ? 0 : Values.largeSpacing)
|
||||
tintColor: info.styling.tintColor,
|
||||
isEnabled: info.isEnabled
|
||||
)
|
||||
|
||||
titleLabel.text = info.title
|
||||
titleLabel.themeTextColor = info.tintColor
|
||||
subtitleLabel.text = info.subtitle
|
||||
subtitleLabel.themeTextColor = info.tintColor
|
||||
subtitleLabel.isHidden = (info.subtitle == nil)
|
||||
extraActionTopSpacingView.isHidden = (info.extraAction == nil)
|
||||
extraActionButton.setTitle(info.extraAction?.title, for: .normal)
|
||||
extraActionButton.isHidden = (info.extraAction == nil)
|
||||
contentStackViewLeadingConstraint.isActive = (info.styling.alignment == .leading)
|
||||
contentStackViewTrailingConstraint.isActive = (info.styling.alignment == .leading)
|
||||
contentStackViewHorizontalCenterConstraint.constant = ((info.styling.customPadding?.leading ?? 0) + (info.styling.customPadding?.trailing ?? 0))
|
||||
contentStackViewHorizontalCenterConstraint.isActive = (info.styling.alignment == .centerHugging)
|
||||
leftAccessoryFillConstraint.isActive = leftFitToEdge
|
||||
rightAccessoryFillConstraint.isActive = rightFitToEdge
|
||||
accessoryWidthMatchConstraint.isActive = {
|
||||
switch (info.leftAccessory, info.rightAccessory) {
|
||||
case (.button, .button): return true
|
||||
default: return false
|
||||
}
|
||||
}()
|
||||
titleLabel.setContentHuggingPriority(
|
||||
(info.rightAccessory != nil ? .defaultLow : .required),
|
||||
for: .horizontal
|
||||
)
|
||||
titleLabel.setContentCompressionResistancePriority(
|
||||
(info.rightAccessory != nil ? .defaultLow : .required),
|
||||
for: .horizontal
|
||||
)
|
||||
contentStackViewTopConstraint.constant = {
|
||||
if let customPadding: CGFloat = info.styling.customPadding?.top {
|
||||
return customPadding
|
||||
}
|
||||
|
||||
return (leftFitToEdge || rightFitToEdge ? 0 : Values.mediumSpacing)
|
||||
}()
|
||||
contentStackViewLeadingConstraint.constant = {
|
||||
if let customPadding: CGFloat = info.styling.customPadding?.leading {
|
||||
return customPadding
|
||||
}
|
||||
|
||||
return (leftFitToEdge ? 0 : Values.mediumSpacing)
|
||||
}()
|
||||
contentStackViewTrailingConstraint.constant = {
|
||||
if let customPadding: CGFloat = info.styling.customPadding?.trailing {
|
||||
return -customPadding
|
||||
}
|
||||
|
||||
return -(rightFitToEdge ? 0 : Values.mediumSpacing)
|
||||
}()
|
||||
contentStackViewBottomConstraint.constant = {
|
||||
if let customPadding: CGFloat = info.styling.customPadding?.bottom {
|
||||
return -customPadding
|
||||
}
|
||||
|
||||
return -(leftFitToEdge || rightFitToEdge ? 0 : Values.mediumSpacing)
|
||||
}()
|
||||
titleTextFieldLeadingConstraint.constant = {
|
||||
guard info.styling.backgroundStyle != .noBackground else { return 0 }
|
||||
|
||||
return (leftFitToEdge ? 0 : Values.mediumSpacing)
|
||||
}()
|
||||
titleTextFieldTrailingConstraint.constant = {
|
||||
guard info.styling.backgroundStyle != .noBackground else { return 0 }
|
||||
|
||||
return -(rightFitToEdge ? 0 : Values.mediumSpacing)
|
||||
}()
|
||||
|
||||
// Styling and positioning
|
||||
let defaultEdgePadding: CGFloat
|
||||
cellBackgroundView.themeBackgroundColor = (info.shouldHaveBackground ?
|
||||
.settings_tabBackground :
|
||||
nil
|
||||
)
|
||||
cellSelectedBackgroundView.isHidden = (!info.isEnabled || !info.shouldHaveBackground)
|
||||
|
||||
switch style {
|
||||
switch info.styling.backgroundStyle {
|
||||
case .rounded:
|
||||
cellBackgroundView.themeBackgroundColor = .settings_tabBackground
|
||||
cellSelectedBackgroundView.isHidden = !info.isEnabled
|
||||
|
||||
defaultEdgePadding = Values.mediumSpacing
|
||||
backgroundLeftConstraint.constant = Values.largeSpacing
|
||||
backgroundRightConstraint.constant = -Values.largeSpacing
|
||||
cellBackgroundView.layer.cornerRadius = SessionCell.cornerRadius
|
||||
|
||||
case .edgeToEdge:
|
||||
cellBackgroundView.themeBackgroundColor = .settings_tabBackground
|
||||
cellSelectedBackgroundView.isHidden = !info.isEnabled
|
||||
|
||||
defaultEdgePadding = 0
|
||||
backgroundLeftConstraint.constant = 0
|
||||
backgroundRightConstraint.constant = 0
|
||||
cellBackgroundView.layer.cornerRadius = 0
|
||||
|
||||
case .roundedEdgeToEdge:
|
||||
case .noBackground:
|
||||
defaultEdgePadding = Values.mediumSpacing
|
||||
backgroundLeftConstraint.constant = 0
|
||||
backgroundRightConstraint.constant = 0
|
||||
cellBackgroundView.layer.cornerRadius = SessionCell.cornerRadius
|
||||
backgroundLeftConstraint.constant = Values.largeSpacing
|
||||
backgroundRightConstraint.constant = -Values.largeSpacing
|
||||
cellBackgroundView.themeBackgroundColor = nil
|
||||
cellBackgroundView.layer.cornerRadius = 0
|
||||
cellSelectedBackgroundView.isHidden = true
|
||||
}
|
||||
|
||||
let fittedEdgePadding: CGFloat = {
|
||||
func targetSize(accessory: Accessory?) -> CGFloat {
|
||||
switch accessory {
|
||||
case .icon(_, let iconSize, _, _), .iconAsync(let iconSize, _, _, _):
|
||||
case .icon(_, let iconSize, _, _, _), .iconAsync(let iconSize, _, _, _, _):
|
||||
return iconSize.size
|
||||
|
||||
default: return defaultEdgePadding
|
||||
|
@ -394,43 +468,103 @@ public class SessionCell: UITableViewCell {
|
|||
botSeparatorLeftConstraint.constant = (leftFitToEdge ? fittedEdgePadding : defaultEdgePadding)
|
||||
botSeparatorRightConstraint.constant = (rightFitToEdge ? -fittedEdgePadding : -defaultEdgePadding)
|
||||
|
||||
switch position {
|
||||
switch info.position {
|
||||
case .top:
|
||||
cellBackgroundView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
|
||||
topSeparator.isHidden = (style != .edgeToEdge)
|
||||
botSeparator.isHidden = false
|
||||
topSeparator.isHidden = (
|
||||
!info.styling.allowedSeparators.contains(.top) ||
|
||||
info.styling.backgroundStyle != .edgeToEdge
|
||||
)
|
||||
botSeparator.isHidden = (
|
||||
!info.styling.allowedSeparators.contains(.bottom) ||
|
||||
info.styling.backgroundStyle == .noBackground
|
||||
)
|
||||
|
||||
case .middle:
|
||||
cellBackgroundView.layer.maskedCorners = []
|
||||
topSeparator.isHidden = true
|
||||
botSeparator.isHidden = false
|
||||
botSeparator.isHidden = (
|
||||
!info.styling.allowedSeparators.contains(.bottom) ||
|
||||
info.styling.backgroundStyle == .noBackground
|
||||
)
|
||||
|
||||
case .bottom:
|
||||
cellBackgroundView.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner]
|
||||
topSeparator.isHidden = false
|
||||
botSeparator.isHidden = (style != .edgeToEdge)
|
||||
topSeparator.isHidden = true
|
||||
botSeparator.isHidden = (
|
||||
!info.styling.allowedSeparators.contains(.bottom) ||
|
||||
info.styling.backgroundStyle != .edgeToEdge
|
||||
)
|
||||
|
||||
case .individual:
|
||||
cellBackgroundView.layer.maskedCorners = [
|
||||
.layerMinXMinYCorner, .layerMaxXMinYCorner,
|
||||
.layerMinXMaxYCorner, .layerMaxXMaxYCorner
|
||||
]
|
||||
topSeparator.isHidden = (style != .edgeToEdge)
|
||||
botSeparator.isHidden = (style != .edgeToEdge)
|
||||
topSeparator.isHidden = (
|
||||
!info.styling.allowedSeparators.contains(.top) ||
|
||||
info.styling.backgroundStyle != .edgeToEdge
|
||||
)
|
||||
botSeparator.isHidden = (
|
||||
!info.styling.allowedSeparators.contains(.bottom) ||
|
||||
info.styling.backgroundStyle != .edgeToEdge
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public func update(isEditing: Bool, animated: Bool) {}
|
||||
public func update(isEditing: Bool, becomeFirstResponder: Bool, animated: Bool) {
|
||||
// Note: We set 'isUserInteractionEnabled' based on the 'info.isEditable' flag
|
||||
// so can use that to determine whether this element can become editable
|
||||
guard interactionMode == .editable || interactionMode == .alwaysEditing else { return }
|
||||
|
||||
self.isEditingTitle = isEditing
|
||||
|
||||
let changes = { [weak self] in
|
||||
self?.titleLabel.alpha = (isEditing ? 0 : 1)
|
||||
self?.titleTextField.alpha = (isEditing ? 1 : 0)
|
||||
self?.leftAccessoryView.alpha = (isEditing ? 0 : 1)
|
||||
self?.rightAccessoryView.alpha = (isEditing ? 0 : 1)
|
||||
self?.titleMinHeightConstraint.isActive = isEditing
|
||||
}
|
||||
let completion: (Bool) -> Void = { [weak self] complete in
|
||||
self?.titleTextField.text = self?.originalInputValue
|
||||
}
|
||||
|
||||
if animated {
|
||||
UIView.animate(withDuration: 0.25, animations: changes, completion: completion)
|
||||
}
|
||||
else {
|
||||
changes()
|
||||
completion(true)
|
||||
}
|
||||
|
||||
if isEditing && becomeFirstResponder {
|
||||
titleTextField.becomeFirstResponder()
|
||||
}
|
||||
else if !isEditing {
|
||||
titleTextField.resignFirstResponder()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Interaction
|
||||
|
||||
public override func setHighlighted(_ highlighted: Bool, animated: Bool) {
|
||||
super.setHighlighted(highlighted, animated: animated)
|
||||
|
||||
// When editing disable the highlighted state changes (would result in UI elements
|
||||
// reappearing otherwise)
|
||||
guard !self.isEditingTitle else { return }
|
||||
|
||||
// If the 'cellSelectedBackgroundView' is hidden then there is no background so we
|
||||
// should update the titleLabel to indicate the highlighted state
|
||||
if cellSelectedBackgroundView.isHidden {
|
||||
titleLabel.alpha = (highlighted ? 0.8 : 1)
|
||||
if cellSelectedBackgroundView.isHidden && shouldHighlightTitle {
|
||||
// Note: We delay the "unhighlight" of the titleLabel so that the transition doesn't
|
||||
// conflict with the transition into edit mode
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { [weak self] in
|
||||
guard self?.isEditingTitle == false else { return }
|
||||
|
||||
self?.titleLabel.alpha = (highlighted ? 0.8 : 1)
|
||||
}
|
||||
}
|
||||
|
||||
cellSelectedBackgroundView.alpha = (highlighted ? 1 : 0)
|
||||
|
@ -440,12 +574,18 @@ public class SessionCell: UITableViewCell {
|
|||
|
||||
public override func setSelected(_ selected: Bool, animated: Bool) {
|
||||
super.setSelected(selected, animated: animated)
|
||||
|
||||
|
||||
leftAccessoryView.setSelected(selected, animated: animated)
|
||||
rightAccessoryView.setSelected(selected, animated: animated)
|
||||
}
|
||||
|
||||
@objc private func extraActionTapped() {
|
||||
onExtraActionTap?()
|
||||
}
|
||||
|
||||
// MARK: - Compose
|
||||
|
||||
extension CombineCompatible where Self: SessionCell {
|
||||
var textPublisher: AnyPublisher<String, Never> {
|
||||
return self.titleTextField.publisher(for: .editingChanged)
|
||||
.map { textField -> String in (textField.text ?? "") }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,34 +4,44 @@ import UIKit
|
|||
import SessionUIKit
|
||||
|
||||
class SessionHeaderView: UITableViewHeaderFooterView {
|
||||
private lazy var emptyHeightConstraint: NSLayoutConstraint = self.heightAnchor
|
||||
.constraint(equalToConstant: (Values.verySmallSpacing * 2))
|
||||
private lazy var filledHeightConstraint: NSLayoutConstraint = self.heightAnchor
|
||||
.constraint(greaterThanOrEqualToConstant: Values.mediumSpacing)
|
||||
|
||||
// MARK: - UI
|
||||
|
||||
private let stackView: UIStackView = {
|
||||
let result: UIStackView = UIStackView()
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.axis = .vertical
|
||||
result.distribution = .fill
|
||||
result.alignment = .fill
|
||||
result.isLayoutMarginsRelativeArrangement = true
|
||||
|
||||
return result
|
||||
}()
|
||||
private lazy var titleLabelConstraints: [NSLayoutConstraint] = [
|
||||
titleLabel.pin(.top, to: .top, of: self, withInset: Values.mediumSpacing),
|
||||
titleLabel.pin(.bottom, to: .bottom, of: self, withInset: -Values.mediumSpacing)
|
||||
]
|
||||
private lazy var titleLabelLeadingConstraint: NSLayoutConstraint = titleLabel.pin(.leading, to: .leading, of: self)
|
||||
private lazy var titleLabelTrailingConstraint: NSLayoutConstraint = titleLabel.pin(.trailing, to: .trailing, of: self)
|
||||
private lazy var titleSeparatorLeadingConstraint: NSLayoutConstraint = titleSeparator.pin(.leading, to: .leading, of: self)
|
||||
private lazy var titleSeparatorTrailingConstraint: NSLayoutConstraint = titleSeparator.pin(.trailing, to: .trailing, of: self)
|
||||
|
||||
private let titleLabel: UILabel = {
|
||||
let result: UILabel = UILabel()
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.font = .systemFont(ofSize: Values.mediumFontSize)
|
||||
result.themeTextColor = .textSecondary
|
||||
result.isHidden = true
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private let separator: UIView = UIView.separator()
|
||||
private let titleSeparator: Separator = {
|
||||
let result: Separator = Separator()
|
||||
result.isHidden = true
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private let loadingIndicator: UIActivityIndicatorView = {
|
||||
let result: UIActivityIndicatorView = UIActivityIndicatorView(style: .medium)
|
||||
result.themeTintColor = .textPrimary
|
||||
result.alpha = 0.5
|
||||
result.startAnimating()
|
||||
result.hidesWhenStopped = true
|
||||
result.isHidden = true
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
|
@ -41,10 +51,9 @@ class SessionHeaderView: UITableViewHeaderFooterView {
|
|||
self.backgroundView = UIView()
|
||||
self.backgroundView?.themeBackgroundColor = .backgroundPrimary
|
||||
|
||||
addSubview(stackView)
|
||||
addSubview(separator)
|
||||
|
||||
stackView.addArrangedSubview(titleLabel)
|
||||
addSubview(titleLabel)
|
||||
addSubview(titleSeparator)
|
||||
addSubview(loadingIndicator)
|
||||
|
||||
setupLayout()
|
||||
}
|
||||
|
@ -54,42 +63,59 @@ class SessionHeaderView: UITableViewHeaderFooterView {
|
|||
}
|
||||
|
||||
private func setupLayout() {
|
||||
stackView.pin(to: self)
|
||||
titleLabel.pin(.top, to: .top, of: self, withInset: Values.mediumSpacing)
|
||||
titleLabel.pin(.bottom, to: .bottom, of: self, withInset: Values.mediumSpacing)
|
||||
titleLabel.center(.vertical, in: self)
|
||||
|
||||
separator.pin(.left, to: .left, of: self)
|
||||
separator.pin(.right, to: .right, of: self)
|
||||
separator.pin(.bottom, to: .bottom, of: self)
|
||||
titleSeparator.center(.vertical, in: self)
|
||||
loadingIndicator.center(in: self)
|
||||
}
|
||||
|
||||
// MARK: - Content
|
||||
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
|
||||
titleLabel.isHidden = true
|
||||
titleSeparator.isHidden = true
|
||||
loadingIndicator.isHidden = true
|
||||
|
||||
titleLabelLeadingConstraint.isActive = false
|
||||
titleLabelTrailingConstraint.isActive = false
|
||||
titleLabelConstraints.forEach { $0.isActive = false }
|
||||
|
||||
titleSeparator.center(.vertical, in: self)
|
||||
titleSeparatorLeadingConstraint.isActive = false
|
||||
titleSeparatorTrailingConstraint.isActive = false
|
||||
}
|
||||
|
||||
public func update(
|
||||
style: SessionCell.Style = .rounded,
|
||||
title: String?,
|
||||
hasSeparator: Bool
|
||||
style: SessionTableSectionStyle = .titleRoundedContent
|
||||
) {
|
||||
let titleIsEmpty: Bool = (title ?? "").isEmpty
|
||||
let edgePadding: CGFloat = {
|
||||
switch style {
|
||||
case .rounded:
|
||||
// Align to the start of the text in the cell
|
||||
return (Values.largeSpacing + Values.mediumSpacing)
|
||||
|
||||
case .edgeToEdge, .roundedEdgeToEdge: return Values.largeSpacing
|
||||
}
|
||||
}()
|
||||
|
||||
titleLabel.text = title
|
||||
titleLabel.isHidden = titleIsEmpty
|
||||
stackView.layoutMargins = UIEdgeInsets(
|
||||
top: (titleIsEmpty ? Values.verySmallSpacing : Values.mediumSpacing),
|
||||
left: edgePadding,
|
||||
bottom: (titleIsEmpty ? Values.verySmallSpacing : Values.mediumSpacing),
|
||||
right: edgePadding
|
||||
)
|
||||
emptyHeightConstraint.isActive = titleIsEmpty
|
||||
filledHeightConstraint.isActive = !titleIsEmpty
|
||||
separator.isHidden = (style == .rounded || !hasSeparator)
|
||||
switch style {
|
||||
case .titleRoundedContent, .titleEdgeToEdgeContent, .titleNoBackgroundContent:
|
||||
titleLabel.text = title
|
||||
titleLabel.isHidden = titleIsEmpty
|
||||
titleLabelLeadingConstraint.constant = style.edgePadding
|
||||
titleLabelTrailingConstraint.constant = -style.edgePadding
|
||||
titleLabelLeadingConstraint.isActive = !titleIsEmpty
|
||||
titleLabelTrailingConstraint.isActive = !titleIsEmpty
|
||||
titleLabelConstraints.forEach { $0.isActive = true }
|
||||
|
||||
case .titleSeparator:
|
||||
titleSeparator.update(title: title)
|
||||
titleSeparator.isHidden = false
|
||||
titleSeparatorLeadingConstraint.constant = style.edgePadding
|
||||
titleSeparatorTrailingConstraint.constant = -style.edgePadding
|
||||
titleSeparatorLeadingConstraint.isActive = !titleIsEmpty
|
||||
titleSeparatorTrailingConstraint.isActive = !titleIsEmpty
|
||||
|
||||
case .none, .padding: break
|
||||
case .loadMore: loadingIndicator.isHidden = false
|
||||
}
|
||||
|
||||
self.layoutIfNeeded()
|
||||
}
|
||||
|
|
|
@ -110,22 +110,14 @@ public final class BackgroundPoller {
|
|||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
let promises: [Promise<Void>] = jobsToRun.map { job -> Promise<Void> in
|
||||
let (promise, seal) = Promise<Void>.pending()
|
||||
|
||||
// Note: In the background we just want jobs to fail silently
|
||||
MessageReceiveJob.run(
|
||||
job,
|
||||
queue: DispatchQueue.main,
|
||||
success: { _, _ in seal.fulfill(()) },
|
||||
failure: { _, _, _ in seal.fulfill(()) },
|
||||
deferred: { _ in seal.fulfill(()) }
|
||||
)
|
||||
|
||||
return promise
|
||||
}
|
||||
|
||||
return when(fulfilled: promises)
|
||||
return ClosedGroupPoller.poll(
|
||||
namespaces: ClosedGroupPoller.namespaces,
|
||||
from: snode,
|
||||
for: groupPublicKey,
|
||||
on: DispatchQueue.main,
|
||||
calledFromBackgroundPoller: true,
|
||||
isBackgroundPollValid: { BackgroundPoller.isValid }
|
||||
)
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
|
|
@ -1,22 +0,0 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
|
||||
enum Header: String {
|
||||
case authorization = "Authorization"
|
||||
case contentType = "Content-Type"
|
||||
case contentDisposition = "Content-Disposition"
|
||||
|
||||
case sogsPubKey = "X-SOGS-Pubkey"
|
||||
case sogsNonce = "X-SOGS-Nonce"
|
||||
case sogsTimestamp = "X-SOGS-Timestamp"
|
||||
case sogsSignature = "X-SOGS-Signature"
|
||||
}
|
||||
|
||||
// MARK: - Convenience
|
||||
|
||||
extension Dictionary where Key == Header, Value == String {
|
||||
func toHTTPHeaders() -> [String: String] {
|
||||
return self.reduce(into: [:]) { result, next in result[next.key.rawValue] = next.value }
|
||||
}
|
||||
}
|
|
@ -1,15 +0,0 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
|
||||
enum QueryParam: String {
|
||||
case publicKey = "public_key"
|
||||
case fromServerId = "from_server_id"
|
||||
|
||||
case required = "required"
|
||||
case limit // For messages - number between 1 and 256 (default is 100)
|
||||
case platform // For file server session version check
|
||||
case updateTypes = "t" // String indicating the types of updates that the client supports
|
||||
|
||||
case reactors = "reactors"
|
||||
}
|
|
@ -92,16 +92,6 @@ public extension ClosedGroup {
|
|||
// MARK: - Convenience
|
||||
|
||||
public extension ClosedGroup {
|
||||
func asProfile() -> Profile {
|
||||
return Profile(
|
||||
id: threadId,
|
||||
name: name,
|
||||
profilePictureUrl: groupImageUrl,
|
||||
profilePictureFileName: groupImageFileName,
|
||||
profileEncryptionKey: groupImageEncryptionKey
|
||||
)
|
||||
}
|
||||
|
||||
static func removeKeysAndUnsubscribe(
|
||||
_ db: Database? = nil,
|
||||
threadId: String,
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import SessionUtilitiesKit
|
||||
|
||||
extension FileServerAPI {
|
||||
public enum Endpoint: EndpointType {
|
||||
|
@ -8,7 +9,7 @@ extension FileServerAPI {
|
|||
case fileIndividual(fileId: String)
|
||||
case sessionVersion
|
||||
|
||||
var path: String {
|
||||
public var path: String {
|
||||
switch self {
|
||||
case .file: return "file"
|
||||
case .fileIndividual(let fileId): return "file/\(fileId)"
|
||||
|
|
|
@ -338,8 +338,6 @@ public final class ClosedGroupControlMessage: ControlMessage {
|
|||
let contentProto = SNProtoContent.builder()
|
||||
let dataMessageProto = SNProtoDataMessage.builder()
|
||||
dataMessageProto.setClosedGroupControlMessage(try closedGroupControlMessage.build())
|
||||
// Group context
|
||||
try setGroupContextIfNeeded(db, on: dataMessageProto)
|
||||
contentProto.setDataMessage(try dataMessageProto.build())
|
||||
return try contentProto.build()
|
||||
} catch {
|
||||
|
|
|
@ -77,13 +77,6 @@ public final class ExpirationTimerUpdate: ControlMessage {
|
|||
dataMessageProto.setFlags(UInt32(SNProtoDataMessage.SNProtoDataMessageFlags.expirationTimerUpdate.rawValue))
|
||||
dataMessageProto.setExpireTimer(duration)
|
||||
if let syncTarget = syncTarget { dataMessageProto.setSyncTarget(syncTarget) }
|
||||
// Group context
|
||||
do {
|
||||
try setGroupContextIfNeeded(db, on: dataMessageProto)
|
||||
} catch {
|
||||
SNLog("Couldn't construct expiration timer update proto from: \(self).")
|
||||
return nil
|
||||
}
|
||||
let contentProto = SNProtoContent.builder()
|
||||
do {
|
||||
contentProto.setDataMessage(try dataMessageProto.build())
|
||||
|
|
|
@ -24,6 +24,13 @@ public extension Message {
|
|||
)
|
||||
case openGroupInbox(server: String, openGroupPublicKey: String, blindedPublicKey: String)
|
||||
|
||||
var namespace: SnodeAPI.Namespace {
|
||||
switch self {
|
||||
case .contact(_, let namespace), .closedGroup(_, let namespace): return namespace
|
||||
default: preconditionFailure("Attepted to retrieve namespace for invalid destination")
|
||||
}
|
||||
}
|
||||
|
||||
public static func from(
|
||||
_ db: Database,
|
||||
thread: SessionThread,
|
||||
|
|
|
@ -63,18 +63,6 @@ public class Message: Codable {
|
|||
public func toProto(_ db: Database) -> SNProtoContent? {
|
||||
preconditionFailure("toProto(_:) is abstract and must be overridden.")
|
||||
}
|
||||
|
||||
public func setGroupContextIfNeeded(_ db: Database, on dataMessage: SNProtoDataMessage.SNProtoDataMessageBuilder) throws {
|
||||
guard
|
||||
let threadId: String = threadId,
|
||||
(try? ClosedGroup.exists(db, id: threadId)) == true,
|
||||
let legacyGroupId: Data = "\(SMKLegacy.closedGroupIdPrefix)\(threadId)".data(using: .utf8)
|
||||
else { return }
|
||||
|
||||
// Android needs a group context or it'll interpret the message as a one-to-one message
|
||||
let groupProto = SNProtoGroupContext.builder(id: legacyGroupId, type: .deliver)
|
||||
dataMessage.setGroup(try groupProto.build())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Message Parsing/Processing
|
||||
|
|
|
@ -158,7 +158,7 @@ public final class VisibleMessage: Message {
|
|||
|
||||
// Attachments
|
||||
|
||||
let attachments: [Attachment]? = try? Attachment.fetchAll(db, ids: self.attachmentIds)
|
||||
let attachments: [Attachment]? = try? Attachment.fetchAll(db, ids: self.attachmentIds)
|
||||
let attachmentProtos = (attachments ?? []).compactMap { $0.buildProto() }
|
||||
dataMessage.setAttachments(attachmentProtos)
|
||||
|
||||
|
@ -175,14 +175,6 @@ public final class VisibleMessage: Message {
|
|||
dataMessage.setReaction(reactionProto)
|
||||
}
|
||||
|
||||
// Group context
|
||||
do {
|
||||
try setGroupContextIfNeeded(db, on: dataMessage)
|
||||
} catch {
|
||||
SNLog("Couldn't construct visible message proto from: \(self).")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Sync target
|
||||
if let syncTarget = syncTarget {
|
||||
dataMessage.setSyncTarget(syncTarget)
|
||||
|
|
|
@ -1,174 +0,0 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import PromiseKit
|
||||
import SessionUtilitiesKit
|
||||
import SessionSnodeKit
|
||||
|
||||
extension OpenGroupAPI {
|
||||
// MARK: - BatchSubRequest
|
||||
|
||||
struct BatchSubRequest: Encodable {
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case method
|
||||
case path
|
||||
case headers
|
||||
case json
|
||||
case b64
|
||||
case bytes
|
||||
}
|
||||
|
||||
let method: HTTP.Verb
|
||||
let path: String
|
||||
let headers: [String: String]?
|
||||
|
||||
/// The `jsonBodyEncoder` is used to avoid having to make `BatchSubRequest` a generic type (haven't found a good way
|
||||
/// to keep `BatchSubRequest` encodable using protocols unfortunately so need this work around)
|
||||
private let jsonBodyEncoder: ((inout KeyedEncodingContainer<CodingKeys>, CodingKeys) throws -> ())?
|
||||
private let b64: String?
|
||||
private let bytes: [UInt8]?
|
||||
|
||||
init<T: Encodable>(request: Request<T, Endpoint>) {
|
||||
self.method = request.method
|
||||
self.path = request.urlPathAndParamsString
|
||||
self.headers = (request.headers.isEmpty ? nil : request.headers.toHTTPHeaders())
|
||||
|
||||
// Note: Need to differentiate between JSON, b64 string and bytes body values to ensure they are
|
||||
// encoded correctly so the server knows how to handle them
|
||||
switch request.body {
|
||||
case let bodyString as String:
|
||||
self.jsonBodyEncoder = nil
|
||||
self.b64 = bodyString
|
||||
self.bytes = nil
|
||||
|
||||
case let bodyBytes as [UInt8]:
|
||||
self.jsonBodyEncoder = nil
|
||||
self.b64 = nil
|
||||
self.bytes = bodyBytes
|
||||
|
||||
default:
|
||||
self.jsonBodyEncoder = { [body = request.body] container, key in
|
||||
try container.encodeIfPresent(body, forKey: key)
|
||||
}
|
||||
self.b64 = nil
|
||||
self.bytes = nil
|
||||
}
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container: KeyedEncodingContainer<CodingKeys> = encoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
try container.encode(method, forKey: .method)
|
||||
try container.encode(path, forKey: .path)
|
||||
try container.encodeIfPresent(headers, forKey: .headers)
|
||||
try jsonBodyEncoder?(&container, .json)
|
||||
try container.encodeIfPresent(b64, forKey: .b64)
|
||||
try container.encodeIfPresent(bytes, forKey: .bytes)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - BatchSubResponse<T>
|
||||
|
||||
struct BatchSubResponse<T: Codable>: Codable {
|
||||
/// The numeric http response code (e.g. 200 for success)
|
||||
let code: Int32
|
||||
|
||||
/// This should always include the content type of the request
|
||||
let headers: [String: String]
|
||||
|
||||
/// The body of the request; will be plain json if content-type is `application/json`, otherwise it will be base64 encoded data
|
||||
let body: T?
|
||||
|
||||
/// A flag to indicate that there was a body but it failed to parse
|
||||
let failedToParseBody: Bool
|
||||
}
|
||||
|
||||
// MARK: - BatchRequestInfo<T, R>
|
||||
|
||||
struct BatchRequestInfo<T: Encodable>: BatchRequestInfoType {
|
||||
let request: Request<T, Endpoint>
|
||||
let responseType: Codable.Type
|
||||
|
||||
var endpoint: Endpoint { request.endpoint }
|
||||
|
||||
init<R: Codable>(request: Request<T, Endpoint>, responseType: R.Type) {
|
||||
self.request = request
|
||||
self.responseType = BatchSubResponse<R>.self
|
||||
}
|
||||
|
||||
init(request: Request<T, Endpoint>) {
|
||||
self.init(
|
||||
request: request,
|
||||
responseType: NoResponse.self
|
||||
)
|
||||
}
|
||||
|
||||
func toSubRequest() -> BatchSubRequest {
|
||||
return BatchSubRequest(request: request)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - BatchRequest
|
||||
|
||||
typealias BatchRequest = [BatchSubRequest]
|
||||
typealias BatchResponseTypes = [Codable.Type]
|
||||
typealias BatchResponse = [(OnionRequestResponseInfoType, Codable?)]
|
||||
}
|
||||
|
||||
extension OpenGroupAPI.BatchSubResponse {
|
||||
init(from decoder: Decoder) throws {
|
||||
let container: KeyedDecodingContainer<CodingKeys> = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let body: T? = try? container.decode(T.self, forKey: .body)
|
||||
|
||||
self = OpenGroupAPI.BatchSubResponse(
|
||||
code: try container.decode(Int32.self, forKey: .code),
|
||||
headers: try container.decode([String: String].self, forKey: .headers),
|
||||
body: body,
|
||||
failedToParseBody: (body == nil && T.self != NoResponse.self && !(T.self is ExpressibleByNilLiteral.Type))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - BatchRequestInfoType
|
||||
|
||||
/// This protocol is designed to erase the types from `BatchRequestInfo<T, R>` so multiple types can be used
|
||||
/// in arrays when doing `/batch` and `/sequence` requests
|
||||
protocol BatchRequestInfoType {
|
||||
var responseType: Codable.Type { get }
|
||||
var endpoint: OpenGroupAPI.Endpoint { get }
|
||||
|
||||
func toSubRequest() -> OpenGroupAPI.BatchSubRequest
|
||||
}
|
||||
|
||||
// MARK: - Convenience
|
||||
|
||||
public extension Decodable {
|
||||
static func decoded(from data: Data, using dependencies: Dependencies = Dependencies()) throws -> Self {
|
||||
return try data.decoded(as: Self.self, using: dependencies)
|
||||
}
|
||||
}
|
||||
|
||||
extension Promise where T == (OnionRequestResponseInfoType, Data?) {
|
||||
func decoded(as types: OpenGroupAPI.BatchResponseTypes, on queue: DispatchQueue? = nil, using dependencies: Dependencies = Dependencies()) -> Promise<OpenGroupAPI.BatchResponse> {
|
||||
self.map(on: queue) { responseInfo, maybeData -> OpenGroupAPI.BatchResponse in
|
||||
// Need to split the data into an array of data so each item can be Decoded correctly
|
||||
guard let data: Data = maybeData else { throw HTTP.Error.parsingFailed }
|
||||
guard let jsonObject: Any = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) else {
|
||||
throw HTTP.Error.parsingFailed
|
||||
}
|
||||
guard let anyArray: [Any] = jsonObject as? [Any] else { throw HTTP.Error.parsingFailed }
|
||||
|
||||
let dataArray: [Data] = anyArray.compactMap { try? JSONSerialization.data(withJSONObject: $0) }
|
||||
guard dataArray.count == types.count else { throw HTTP.Error.parsingFailed }
|
||||
|
||||
do {
|
||||
return try zip(dataArray, types)
|
||||
.map { data, type in try type.decoded(from: data, using: dependencies) }
|
||||
.map { data in (responseInfo, data) }
|
||||
}
|
||||
catch {
|
||||
throw HTTP.Error.parsingFailed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -67,10 +67,10 @@ extension OpenGroupAPI.Message {
|
|||
// If we have data and a signature (ie. the message isn't a deletion) then validate the signature
|
||||
if let base64EncodedData: String = maybeBase64EncodedData, let base64EncodedSignature: String = maybeBase64EncodedSignature {
|
||||
guard let sender: String = maybeSender, let data = Data(base64Encoded: base64EncodedData), let signature = Data(base64Encoded: base64EncodedSignature) else {
|
||||
throw HTTP.Error.parsingFailed
|
||||
throw HTTPError.parsingFailed
|
||||
}
|
||||
guard let dependencies: SMKDependencies = decoder.userInfo[Dependencies.userInfoKey] as? SMKDependencies else {
|
||||
throw HTTP.Error.parsingFailed
|
||||
throw HTTPError.parsingFailed
|
||||
}
|
||||
|
||||
// Verify the signature based on the SessionId.Prefix type
|
||||
|
@ -80,18 +80,18 @@ extension OpenGroupAPI.Message {
|
|||
case .blinded:
|
||||
guard dependencies.sign.verify(message: data.bytes, publicKey: publicKey.bytes, signature: signature.bytes) else {
|
||||
SNLog("Ignoring message with invalid signature.")
|
||||
throw HTTP.Error.parsingFailed
|
||||
throw HTTPError.parsingFailed
|
||||
}
|
||||
|
||||
case .standard, .unblinded:
|
||||
guard (try? dependencies.ed25519.verifySignature(signature, publicKey: publicKey, data: data)) == true else {
|
||||
SNLog("Ignoring message with invalid signature.")
|
||||
throw HTTP.Error.parsingFailed
|
||||
throw HTTPError.parsingFailed
|
||||
}
|
||||
|
||||
case .none:
|
||||
SNLog("Ignoring message with invalid sender.")
|
||||
throw HTTP.Error.parsingFailed
|
||||
throw HTTPError.parsingFailed
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -54,8 +54,8 @@ public enum OpenGroupAPI {
|
|||
.defaulting(to: [])
|
||||
|
||||
// Generate the requests
|
||||
let requestResponseType: [BatchRequestInfoType] = [
|
||||
BatchRequestInfo(
|
||||
let requestResponseType: [BatchRequest.Info] = [
|
||||
BatchRequest.Info(
|
||||
request: Request<NoBody, Endpoint>(
|
||||
server: server,
|
||||
endpoint: .capabilities
|
||||
|
@ -71,7 +71,7 @@ public enum OpenGroupAPI {
|
|||
.filter(OpenGroup.Columns.roomToken != "")
|
||||
.fetchAll(db))
|
||||
.defaulting(to: [])
|
||||
.flatMap { openGroup -> [BatchRequestInfoType] in
|
||||
.flatMap { openGroup -> [BatchRequest.Info] in
|
||||
let shouldRetrieveRecentMessages: Bool = (
|
||||
openGroup.sequenceNumber == 0 || (
|
||||
// If it's the first poll for this launch and it's been longer than
|
||||
|
@ -83,14 +83,14 @@ public enum OpenGroupAPI {
|
|||
)
|
||||
|
||||
return [
|
||||
BatchRequestInfo(
|
||||
BatchRequest.Info(
|
||||
request: Request<NoBody, Endpoint>(
|
||||
server: server,
|
||||
endpoint: .roomPollInfo(openGroup.roomToken, openGroup.infoUpdates)
|
||||
),
|
||||
responseType: RoomPollInfo.self
|
||||
),
|
||||
BatchRequestInfo(
|
||||
BatchRequest.Info(
|
||||
request: Request<NoBody, Endpoint>(
|
||||
server: server,
|
||||
endpoint: (shouldRetrieveRecentMessages ?
|
||||
|
@ -113,7 +113,7 @@ public enum OpenGroupAPI {
|
|||
!capabilities.contains(.blind) ? [] :
|
||||
[
|
||||
// Inbox
|
||||
BatchRequestInfo(
|
||||
BatchRequest.Info(
|
||||
request: Request<NoBody, Endpoint>(
|
||||
server: server,
|
||||
endpoint: (lastInboxMessageId == 0 ?
|
||||
|
@ -125,7 +125,7 @@ public enum OpenGroupAPI {
|
|||
),
|
||||
|
||||
// Outbox
|
||||
BatchRequestInfo(
|
||||
BatchRequest.Info(
|
||||
request: Request<NoBody, Endpoint>(
|
||||
server: server,
|
||||
endpoint: (lastOutboxMessageId == 0 ?
|
||||
|
@ -151,7 +151,7 @@ public enum OpenGroupAPI {
|
|||
private static func batch(
|
||||
_ db: Database,
|
||||
server: String,
|
||||
requests: [BatchRequestInfoType],
|
||||
requests: [BatchRequest.Info],
|
||||
using dependencies: SMKDependencies = SMKDependencies()
|
||||
) -> AnyPublisher<[Endpoint: (ResponseInfoType, Codable?)], Error> {
|
||||
let responseTypes = requests.map { $0.responseType }
|
||||
|
@ -163,7 +163,7 @@ public enum OpenGroupAPI {
|
|||
method: .post,
|
||||
server: server,
|
||||
endpoint: Endpoint.batch,
|
||||
body: requestBody
|
||||
body: BatchRequest(requests: requests)
|
||||
),
|
||||
using: dependencies
|
||||
)
|
||||
|
@ -183,7 +183,7 @@ public enum OpenGroupAPI {
|
|||
private static func sequence(
|
||||
_ db: Database,
|
||||
server: String,
|
||||
requests: [BatchRequestInfoType],
|
||||
requests: [BatchRequest.Info],
|
||||
using dependencies: SMKDependencies = SMKDependencies()
|
||||
) -> AnyPublisher<[Endpoint: (ResponseInfoType, Codable?)], Error> {
|
||||
let responseTypes = requests.map { $0.responseType }
|
||||
|
@ -195,7 +195,7 @@ public enum OpenGroupAPI {
|
|||
method: .post,
|
||||
server: server,
|
||||
endpoint: Endpoint.sequence,
|
||||
body: requestBody
|
||||
body: BatchRequest(requests: requests)
|
||||
),
|
||||
using: dependencies
|
||||
)
|
||||
|
@ -315,7 +315,7 @@ public enum OpenGroupAPI {
|
|||
) -> AnyPublisher<(capabilities: (info: ResponseInfoType, data: Capabilities), room: (info: ResponseInfoType, data: Room)), Error> {
|
||||
let requestResponseType: [BatchRequest.Info] = [
|
||||
// Get the latest capabilities for the server (in case it's a new server or the cached ones are stale)
|
||||
BatchRequestInfo(
|
||||
BatchRequest.Info(
|
||||
request: Request<NoBody, Endpoint>(
|
||||
server: server,
|
||||
endpoint: .capabilities
|
||||
|
@ -324,7 +324,7 @@ public enum OpenGroupAPI {
|
|||
),
|
||||
|
||||
// And the room info
|
||||
BatchRequestInfo(
|
||||
BatchRequest.Info(
|
||||
request: Request<NoBody, Endpoint>(
|
||||
server: server,
|
||||
endpoint: .room(roomToken)
|
||||
|
@ -351,13 +351,13 @@ public enum OpenGroupAPI {
|
|||
}
|
||||
})
|
||||
.map { _, value in value }
|
||||
let maybeRoom: (info: OnionRequestResponseInfoType, data: Room?)? = maybeRoomResponse
|
||||
.map { info, data in (info, (data as? BatchSubResponse<Room>)?.body) }
|
||||
let maybeRoom: (info: ResponseInfoType, data: Room?)? = maybeRoomResponse
|
||||
.map { info, data in (info, (data as? HTTP.BatchSubResponse<Room>)?.body) }
|
||||
|
||||
guard
|
||||
let capabilitiesInfo: OnionRequestResponseInfoType = maybeCapabilities?.info,
|
||||
let capabilitiesInfo: ResponseInfoType = maybeCapabilities?.info,
|
||||
let capabilities: Capabilities = maybeCapabilities?.data,
|
||||
let roomInfo: OnionRequestResponseInfoType = maybeRoom?.info,
|
||||
let roomInfo: ResponseInfoType = maybeRoom?.info,
|
||||
let room: Room = maybeRoom?.data
|
||||
else {
|
||||
return Fail(error: HTTPError.parsingFailed)
|
||||
|
@ -383,7 +383,7 @@ public enum OpenGroupAPI {
|
|||
) -> AnyPublisher<(capabilities: (info: ResponseInfoType, data: Capabilities), rooms: (info: ResponseInfoType, data: [Room])), Error> {
|
||||
let requestResponseType: [BatchRequest.Info] = [
|
||||
// Get the latest capabilities for the server (in case it's a new server or the cached ones are stale)
|
||||
BatchRequestInfo(
|
||||
BatchRequest.Info(
|
||||
request: Request<NoBody, Endpoint>(
|
||||
server: server,
|
||||
endpoint: .capabilities
|
||||
|
@ -392,7 +392,7 @@ public enum OpenGroupAPI {
|
|||
),
|
||||
|
||||
// And the room info
|
||||
BatchRequestInfo(
|
||||
BatchRequest.Info(
|
||||
request: Request<NoBody, Endpoint>(
|
||||
server: server,
|
||||
endpoint: .rooms
|
||||
|
@ -419,13 +419,13 @@ public enum OpenGroupAPI {
|
|||
}
|
||||
})
|
||||
.map { _, value in value }
|
||||
let maybeRooms: (info: OnionRequestResponseInfoType, data: [Room]?)? = maybeRoomResponse
|
||||
.map { info, data in (info, (data as? BatchSubResponse<[Room]>)?.body) }
|
||||
let maybeRooms: (info: ResponseInfoType, data: [Room]?)? = maybeRoomResponse
|
||||
.map { info, data in (info, (data as? HTTP.BatchSubResponse<[Room]>)?.body) }
|
||||
|
||||
guard
|
||||
let capabilitiesInfo: OnionRequestResponseInfoType = maybeCapabilities?.info,
|
||||
let capabilitiesInfo: ResponseInfoType = maybeCapabilities?.info,
|
||||
let capabilities: Capabilities = maybeCapabilities?.data,
|
||||
let roomsInfo: OnionRequestResponseInfoType = maybeRooms?.info,
|
||||
let roomsInfo: ResponseInfoType = maybeRooms?.info,
|
||||
let rooms: [Room] = maybeRooms?.data
|
||||
else {
|
||||
return Fail(error: HTTPError.parsingFailed)
|
||||
|
@ -1239,16 +1239,16 @@ public enum OpenGroupAPI {
|
|||
)
|
||||
|
||||
// Generate the requests
|
||||
let requestResponseType: [BatchRequestInfoType] = [
|
||||
BatchRequestInfo(
|
||||
request: Request(
|
||||
let requestResponseType: [BatchRequest.Info] = [
|
||||
BatchRequest.Info(
|
||||
request: Request<UserBanRequest, Endpoint>(
|
||||
method: .post,
|
||||
server: server,
|
||||
endpoint: .userBan(sessionId),
|
||||
body: banRequestBody
|
||||
)
|
||||
),
|
||||
BatchRequestInfo(
|
||||
BatchRequest.Info(
|
||||
request: Request<NoBody, Endpoint>(
|
||||
method: .delete,
|
||||
server: server,
|
||||
|
@ -1390,10 +1390,10 @@ public enum OpenGroupAPI {
|
|||
|
||||
updatedRequest.allHTTPHeaderFields = (request.allHTTPHeaderFields ?? [:])
|
||||
.updated(with: [
|
||||
Header.sogsPubKey.rawValue: signResult.publicKey,
|
||||
Header.sogsTimestamp.rawValue: "\(timestamp)",
|
||||
Header.sogsNonce.rawValue: nonce.base64EncodedString(),
|
||||
Header.sogsSignature.rawValue: signResult.signature.toBase64()
|
||||
HTTPHeader.sogsPubKey: signResult.publicKey,
|
||||
HTTPHeader.sogsTimestamp: "\(timestamp)",
|
||||
HTTPHeader.sogsNonce: nonce.base64EncodedString(),
|
||||
HTTPHeader.sogsSignature: signResult.signature.toBase64()
|
||||
])
|
||||
|
||||
return updatedRequest
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import SessionUtilitiesKit
|
||||
|
||||
public extension HTTPHeader {
|
||||
static let sogsPubKey: HTTPHeader = "X-SOGS-Pubkey"
|
||||
static let sogsNonce: HTTPHeader = "X-SOGS-Nonce"
|
||||
static let sogsTimestamp: HTTPHeader = "X-SOGS-Timestamp"
|
||||
static let sogsSignature: HTTPHeader = "X-SOGS-Signature"
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import SessionUtilitiesKit
|
||||
|
||||
public extension HTTPQueryParam {
|
||||
static let publicKey: HTTPQueryParam = "public_key"
|
||||
static let fromServerId: HTTPQueryParam = "from_server_id"
|
||||
|
||||
static let required: HTTPQueryParam = "required"
|
||||
|
||||
/// For messages - number between 1 and 256 (default is 100)
|
||||
static let limit: HTTPQueryParam = "limit"
|
||||
|
||||
/// For file server session version check
|
||||
static let platform: HTTPQueryParam = "platform"
|
||||
|
||||
/// String indicating the types of updates that the client supports
|
||||
static let updateTypes: HTTPQueryParam = "t"
|
||||
static let reactors: HTTPQueryParam = "reactors"
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import SessionUtilitiesKit
|
||||
|
||||
extension OpenGroupAPI {
|
||||
public enum Endpoint: EndpointType {
|
||||
|
@ -58,7 +59,7 @@ extension OpenGroupAPI {
|
|||
case userUnban(String)
|
||||
case userModerator(String)
|
||||
|
||||
var path: String {
|
||||
public var path: String {
|
||||
switch self {
|
||||
// Utility
|
||||
|
||||
|
|
|
@ -0,0 +1,93 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
import Foundation
|
||||
import GRDB
|
||||
import Sodium
|
||||
import SessionSnodeKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
public class SMKDependencies: SSKDependencies {
|
||||
internal var _sodium: Atomic<SodiumType?>
|
||||
public var sodium: SodiumType {
|
||||
get { Dependencies.getValueSettingIfNull(&_sodium) { Sodium() } }
|
||||
set { _sodium.mutate { $0 = newValue } }
|
||||
}
|
||||
|
||||
internal var _box: Atomic<BoxType?>
|
||||
public var box: BoxType {
|
||||
get { Dependencies.getValueSettingIfNull(&_box) { sodium.getBox() } }
|
||||
set { _box.mutate { $0 = newValue } }
|
||||
}
|
||||
|
||||
internal var _genericHash: Atomic<GenericHashType?>
|
||||
public var genericHash: GenericHashType {
|
||||
get { Dependencies.getValueSettingIfNull(&_genericHash) { sodium.getGenericHash() } }
|
||||
set { _genericHash.mutate { $0 = newValue } }
|
||||
}
|
||||
|
||||
internal var _sign: Atomic<SignType?>
|
||||
public var sign: SignType {
|
||||
get { Dependencies.getValueSettingIfNull(&_sign) { sodium.getSign() } }
|
||||
set { _sign.mutate { $0 = newValue } }
|
||||
}
|
||||
|
||||
internal var _aeadXChaCha20Poly1305Ietf: Atomic<AeadXChaCha20Poly1305IetfType?>
|
||||
public var aeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType {
|
||||
get { Dependencies.getValueSettingIfNull(&_aeadXChaCha20Poly1305Ietf) { sodium.getAeadXChaCha20Poly1305Ietf() } }
|
||||
set { _aeadXChaCha20Poly1305Ietf.mutate { $0 = newValue } }
|
||||
}
|
||||
|
||||
internal var _ed25519: Atomic<Ed25519Type?>
|
||||
public var ed25519: Ed25519Type {
|
||||
get { Dependencies.getValueSettingIfNull(&_ed25519) { Ed25519Wrapper() } }
|
||||
set { _ed25519.mutate { $0 = newValue } }
|
||||
}
|
||||
|
||||
internal var _nonceGenerator16: Atomic<NonceGenerator16ByteType?>
|
||||
public var nonceGenerator16: NonceGenerator16ByteType {
|
||||
get { Dependencies.getValueSettingIfNull(&_nonceGenerator16) { OpenGroupAPI.NonceGenerator16Byte() } }
|
||||
set { _nonceGenerator16.mutate { $0 = newValue } }
|
||||
}
|
||||
|
||||
internal var _nonceGenerator24: Atomic<NonceGenerator24ByteType?>
|
||||
public var nonceGenerator24: NonceGenerator24ByteType {
|
||||
get { Dependencies.getValueSettingIfNull(&_nonceGenerator24) { OpenGroupAPI.NonceGenerator24Byte() } }
|
||||
set { _nonceGenerator24.mutate { $0 = newValue } }
|
||||
}
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
public init(
|
||||
onionApi: OnionRequestAPIType.Type? = nil,
|
||||
generalCache: Atomic<GeneralCacheType>? = nil,
|
||||
storage: Storage? = nil,
|
||||
scheduler: ValueObservationScheduler? = nil,
|
||||
sodium: SodiumType? = nil,
|
||||
box: BoxType? = nil,
|
||||
genericHash: GenericHashType? = nil,
|
||||
sign: SignType? = nil,
|
||||
aeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType? = nil,
|
||||
ed25519: Ed25519Type? = nil,
|
||||
nonceGenerator16: NonceGenerator16ByteType? = nil,
|
||||
nonceGenerator24: NonceGenerator24ByteType? = nil,
|
||||
standardUserDefaults: UserDefaultsType? = nil,
|
||||
date: Date? = nil
|
||||
) {
|
||||
_sodium = Atomic(sodium)
|
||||
_box = Atomic(box)
|
||||
_genericHash = Atomic(genericHash)
|
||||
_sign = Atomic(sign)
|
||||
_aeadXChaCha20Poly1305Ietf = Atomic(aeadXChaCha20Poly1305Ietf)
|
||||
_ed25519 = Atomic(ed25519)
|
||||
_nonceGenerator16 = Atomic(nonceGenerator16)
|
||||
_nonceGenerator24 = Atomic(nonceGenerator24)
|
||||
|
||||
super.init(
|
||||
onionApi: onionApi,
|
||||
generalCache: generalCache,
|
||||
storage: storage,
|
||||
scheduler: scheduler,
|
||||
standardUserDefaults: standardUserDefaults,
|
||||
date: date
|
||||
)
|
||||
}
|
||||
}
|
|
@ -140,7 +140,7 @@ extension MessageReceiver {
|
|||
).insert(db)
|
||||
|
||||
// Start polling
|
||||
ClosedGroupPoller.shared.startPolling(for: groupPublicKey)
|
||||
ClosedGroupPoller.shared.startIfNeeded(for: groupPublicKey)
|
||||
|
||||
// Notify the PN server
|
||||
let _ = PushNotificationAPI.performOperation(.subscribe, for: groupPublicKey, publicKey: getUserHexEncodedPublicKey(db))
|
||||
|
|
|
@ -83,7 +83,7 @@ extension MessageSender {
|
|||
.map { memberId -> MessageSender.PreparedSendData in
|
||||
try MessageSender.preparedSendData(
|
||||
db,
|
||||
message: LegacyClosedGroupControlMessage(
|
||||
message: ClosedGroupControlMessage(
|
||||
kind: .new(
|
||||
publicKey: Data(hex: groupPublicKey),
|
||||
name: name,
|
||||
|
@ -99,7 +99,7 @@ extension MessageSender {
|
|||
// the 'ClosedGroup' object we created
|
||||
sentTimestampMs: UInt64(floor(formationTimestamp * 1000))
|
||||
),
|
||||
to: .contact(publicKey: memberId),
|
||||
to: .contact(publicKey: memberId, namespace: .default),
|
||||
interactionId: nil
|
||||
)
|
||||
}
|
||||
|
@ -263,7 +263,7 @@ extension MessageSender {
|
|||
threadId: thread.id,
|
||||
authorId: userPublicKey,
|
||||
variant: .infoClosedGroupUpdated,
|
||||
body: LegacyClosedGroupControlMessage.Kind
|
||||
body: ClosedGroupControlMessage.Kind
|
||||
.nameChange(name: name)
|
||||
.infoMessage(db, sender: userPublicKey),
|
||||
timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000))
|
||||
|
@ -274,7 +274,7 @@ extension MessageSender {
|
|||
// Send the update to the group
|
||||
try MessageSender.send(
|
||||
db,
|
||||
message: LegacyClosedGroupControlMessage(kind: .nameChange(name: name)),
|
||||
message: ClosedGroupControlMessage(kind: .nameChange(name: name)),
|
||||
interactionId: interactionId,
|
||||
in: thread
|
||||
)
|
||||
|
@ -493,7 +493,7 @@ extension MessageSender {
|
|||
preparedSendData: try MessageSender
|
||||
.preparedSendData(
|
||||
db,
|
||||
message: LegacyClosedGroupControlMessage(
|
||||
message: ClosedGroupControlMessage(
|
||||
kind: .membersRemoved(
|
||||
members: removedMembers.map { Data(hex: $0) }
|
||||
)
|
||||
|
@ -546,7 +546,7 @@ extension MessageSender {
|
|||
threadId: thread.id,
|
||||
authorId: userPublicKey,
|
||||
variant: .infoClosedGroupCurrentUserLeft,
|
||||
body: LegacyClosedGroupControlMessage.Kind
|
||||
body: ClosedGroupControlMessage.Kind
|
||||
.memberLeft
|
||||
.infoMessage(db, sender: userPublicKey),
|
||||
timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000))
|
||||
|
@ -561,7 +561,7 @@ extension MessageSender {
|
|||
sendData = try MessageSender
|
||||
.preparedSendData(
|
||||
db,
|
||||
message: LegacyClosedGroupControlMessage(
|
||||
message: ClosedGroupControlMessage(
|
||||
kind: .memberLeft
|
||||
),
|
||||
to: try Message.Destination.from(db, thread: thread),
|
||||
|
|
|
@ -85,8 +85,8 @@ extension MessageSender {
|
|||
|
||||
let threadId: String = {
|
||||
switch destination {
|
||||
case .contact(let publicKey): return publicKey
|
||||
case .closedGroup(let groupPublicKey): return groupPublicKey
|
||||
case .contact(let publicKey, _): return publicKey
|
||||
case .closedGroup(let groupPublicKey, _): return groupPublicKey
|
||||
case .openGroup(let roomToken, let server, _, _, _):
|
||||
return OpenGroup.idFor(roomToken: roomToken, server: server)
|
||||
|
||||
|
@ -162,7 +162,10 @@ extension MessageSender {
|
|||
// If we don't have a userKeyPair yet then there is no need to sync the configuration
|
||||
// as the user doesn't exist yet (this will get triggered on the first launch of a
|
||||
// fresh install due to the migrations getting run)
|
||||
guard Identity.userExists(db) else {
|
||||
guard
|
||||
Identity.userExists(db),
|
||||
let ed25519SecretKey: [UInt8] = Identity.fetchUserEd25519KeyPair(db)?.secretKey
|
||||
else {
|
||||
return Fail(error: StorageError.generic)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
@ -205,41 +208,31 @@ extension MessageSender {
|
|||
to: legacyDestination,
|
||||
interactionId: nil
|
||||
)
|
||||
|
||||
when(
|
||||
resolved: try userConfigMessageChanges.map { message in
|
||||
try MessageSender
|
||||
.sendImmediate(
|
||||
db,
|
||||
message: message,
|
||||
to: destination,
|
||||
interactionId: nil
|
||||
)
|
||||
|
||||
let userConfigSendData: [PreparedSendData] = try userConfigMessageChanges
|
||||
.map { message in
|
||||
try MessageSender.preparedSendData(
|
||||
db,
|
||||
message: message,
|
||||
to: destination,
|
||||
interactionId: nil
|
||||
)
|
||||
}
|
||||
)
|
||||
.done { results in
|
||||
let hadError: Bool = results.contains { result in
|
||||
switch result {
|
||||
case .fulfilled: return false
|
||||
case .rejected: return true
|
||||
}
|
||||
}
|
||||
|
||||
guard !hadError else {
|
||||
seal.reject(StorageError.generic)
|
||||
return
|
||||
}
|
||||
|
||||
seal.fulfill(())
|
||||
}
|
||||
.catch { _ in seal.reject(StorageError.generic) }
|
||||
.retainUntilComplete()
|
||||
|
||||
/// We want to avoid blocking the db write thread so we dispatch the API call to a different thread
|
||||
return Just(())
|
||||
.setFailureType(to: Error.self)
|
||||
.receive(on: DispatchQueue.global(qos: .userInitiated))
|
||||
.flatMap { _ in MessageSender.sendImmediate(preparedSendData: sendData) }
|
||||
.flatMap { _ -> AnyPublisher<Void, Error> in
|
||||
Publishers
|
||||
.MergeMany(
|
||||
([sendData] + userConfigSendData)
|
||||
.map { MessageSender.sendImmediate(preparedSendData: $0) }
|
||||
)
|
||||
.collect()
|
||||
.map { _ in () }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23,10 +23,6 @@ public final class MessageSender {
|
|||
let plaintext: Data?
|
||||
let ciphertext: Data?
|
||||
|
||||
// TODO: Replace these with the target namespaces
|
||||
let isClosedGroupMessage: Bool
|
||||
let isConfigMessage: Bool
|
||||
|
||||
private init(
|
||||
shouldSend: Bool,
|
||||
message: Message?,
|
||||
|
@ -36,9 +32,7 @@ public final class MessageSender {
|
|||
totalAttachmentsUploaded: Int = 0,
|
||||
snodeMessage: SnodeMessage?,
|
||||
plaintext: Data?,
|
||||
ciphertext: Data?,
|
||||
isClosedGroupMessage: Bool,
|
||||
isConfigMessage: Bool
|
||||
ciphertext: Data?
|
||||
) {
|
||||
self.shouldSend = shouldSend
|
||||
|
||||
|
@ -51,8 +45,6 @@ public final class MessageSender {
|
|||
self.snodeMessage = snodeMessage
|
||||
self.plaintext = plaintext
|
||||
self.ciphertext = ciphertext
|
||||
self.isClosedGroupMessage = isClosedGroupMessage
|
||||
self.isConfigMessage = isConfigMessage
|
||||
}
|
||||
|
||||
// The default constructor creats an instance that doesn't actually send a message
|
||||
|
@ -68,8 +60,6 @@ public final class MessageSender {
|
|||
self.snodeMessage = nil
|
||||
self.plaintext = nil
|
||||
self.ciphertext = nil
|
||||
self.isClosedGroupMessage = false
|
||||
self.isConfigMessage = false
|
||||
}
|
||||
|
||||
/// This should be used to send a message to one-to-one or closed group conversations
|
||||
|
@ -78,9 +68,7 @@ public final class MessageSender {
|
|||
destination: Message.Destination,
|
||||
interactionId: Int64?,
|
||||
isSyncMessage: Bool?,
|
||||
snodeMessage: SnodeMessage,
|
||||
isClosedGroupMessage: Bool,
|
||||
isConfigMessage: Bool
|
||||
snodeMessage: SnodeMessage
|
||||
) {
|
||||
self.shouldSend = true
|
||||
|
||||
|
@ -93,8 +81,6 @@ public final class MessageSender {
|
|||
self.snodeMessage = snodeMessage
|
||||
self.plaintext = nil
|
||||
self.ciphertext = nil
|
||||
self.isClosedGroupMessage = isClosedGroupMessage
|
||||
self.isConfigMessage = isConfigMessage
|
||||
}
|
||||
|
||||
/// This should be used to send a message to open group conversations
|
||||
|
@ -115,8 +101,6 @@ public final class MessageSender {
|
|||
self.snodeMessage = nil
|
||||
self.plaintext = plaintext
|
||||
self.ciphertext = nil
|
||||
self.isClosedGroupMessage = false
|
||||
self.isConfigMessage = false
|
||||
}
|
||||
|
||||
/// This should be used to send a message to an open group inbox
|
||||
|
@ -137,8 +121,6 @@ public final class MessageSender {
|
|||
self.snodeMessage = nil
|
||||
self.plaintext = nil
|
||||
self.ciphertext = ciphertext
|
||||
self.isClosedGroupMessage = false
|
||||
self.isConfigMessage = false
|
||||
}
|
||||
|
||||
// MARK: - Mutation
|
||||
|
@ -153,9 +135,7 @@ public final class MessageSender {
|
|||
totalAttachmentsUploaded: fileIds.count,
|
||||
snodeMessage: snodeMessage,
|
||||
plaintext: plaintext,
|
||||
ciphertext: ciphertext,
|
||||
isClosedGroupMessage: isClosedGroupMessage,
|
||||
isConfigMessage: isConfigMessage
|
||||
ciphertext: ciphertext
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -333,18 +313,15 @@ public final class MessageSender {
|
|||
// Wrap the result
|
||||
let kind: SNProtoEnvelope.SNProtoEnvelopeType
|
||||
let senderPublicKey: String
|
||||
let namespace: SnodeAPI.Namespace
|
||||
|
||||
switch destination {
|
||||
case .contact(_, let targetNamespace):
|
||||
case .contact:
|
||||
kind = .sessionMessage
|
||||
senderPublicKey = ""
|
||||
namespace = targetNamespace
|
||||
|
||||
case .closedGroup(let groupPublicKey, let targetNamespace):
|
||||
case .closedGroup(let groupPublicKey, _):
|
||||
kind = .closedGroupMessage
|
||||
senderPublicKey = groupPublicKey
|
||||
namespace = targetNamespace
|
||||
|
||||
case .openGroup, .openGroupInbox: preconditionFailure()
|
||||
}
|
||||
|
@ -384,9 +361,7 @@ public final class MessageSender {
|
|||
destination: destination,
|
||||
interactionId: interactionId,
|
||||
isSyncMessage: isSyncMessage,
|
||||
snodeMessage: snodeMessage,
|
||||
isClosedGroupMessage: (kind == .closedGroupMessage),
|
||||
isConfigMessage: (message is ConfigurationMessage)
|
||||
snodeMessage: snodeMessage
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -667,10 +642,7 @@ public final class MessageSender {
|
|||
return SnodeAPI
|
||||
.sendMessage(
|
||||
snodeMessage,
|
||||
in: (data.isClosedGroupMessage ?
|
||||
.legacyClosedGroup :
|
||||
.default
|
||||
)
|
||||
in: destination.namespace
|
||||
)
|
||||
.subscribe(on: DispatchQueue.global(qos: .default))
|
||||
.flatMap { result, totalCount -> AnyPublisher<Bool, Error> in
|
||||
|
@ -1014,7 +986,7 @@ public final class MessageSender {
|
|||
data: try prepareSendToSnodeDestination(
|
||||
db,
|
||||
message: message,
|
||||
to: .contact(publicKey: userPublicKey),
|
||||
to: .contact(publicKey: userPublicKey, namespace: namespace),
|
||||
interactionId: interactionId,
|
||||
userPublicKey: userPublicKey,
|
||||
messageSendTimestamp: Int64(floor(Date().timeIntervalSince1970 * 1000)),
|
||||
|
|
|
@ -90,7 +90,7 @@ public enum PushNotificationAPI {
|
|||
let url = URL(string: "\(server)/unregister")!
|
||||
var request: URLRequest = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.allHTTPHeaderFields = [ Header.contentType.rawValue: "application/json" ]
|
||||
request.allHTTPHeaderFields = [ HTTPHeader.contentType: "application/json" ]
|
||||
request.httpBody = body
|
||||
|
||||
return OnionRequestAPI
|
||||
|
@ -144,7 +144,7 @@ public enum PushNotificationAPI {
|
|||
let url = URL(string: "\(server)/register")!
|
||||
var request: URLRequest = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.allHTTPHeaderFields = [ Header.contentType.rawValue: "application/json" ]
|
||||
request.allHTTPHeaderFields = [ HTTPHeader.contentType: "application/json" ]
|
||||
request.httpBody = body
|
||||
|
||||
return Publishers
|
||||
|
@ -227,7 +227,7 @@ public enum PushNotificationAPI {
|
|||
let url = URL(string: "\(server)/\(operation.endpoint)")!
|
||||
var request: URLRequest = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.allHTTPHeaderFields = [ Header.contentType.rawValue: "application/json" ]
|
||||
request.allHTTPHeaderFields = [ HTTPHeader.contentType: "application/json" ]
|
||||
request.httpBody = body
|
||||
|
||||
return OnionRequestAPI
|
||||
|
@ -272,7 +272,7 @@ public enum PushNotificationAPI {
|
|||
let url = URL(string: "\(server)/notify")!
|
||||
var request: URLRequest = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.allHTTPHeaderFields = [ Header.contentType.rawValue: "application/json" ]
|
||||
request.allHTTPHeaderFields = [ HTTPHeader.contentType: "application/json" ]
|
||||
request.httpBody = body
|
||||
|
||||
return OnionRequestAPI
|
||||
|
|
|
@ -8,7 +8,7 @@ import SessionUtilitiesKit
|
|||
|
||||
extension OpenGroupAPI {
|
||||
public final class Poller {
|
||||
typealias PollResponse = [OpenGroupAPI.Endpoint: (info: OnionRequestResponseInfoType, data: Codable?)]
|
||||
typealias PollResponse = [OpenGroupAPI.Endpoint: (info: ResponseInfoType, data: Codable?)]
|
||||
|
||||
private let server: String
|
||||
private var timer: Timer? = nil
|
||||
|
@ -283,7 +283,7 @@ extension OpenGroupAPI {
|
|||
.filter { endpoint, endpointResponse in
|
||||
switch endpoint {
|
||||
case .capabilities:
|
||||
guard (endpointResponse.data as? BatchSubResponse<Capabilities>)?.body != nil else {
|
||||
guard (endpointResponse.data as? HTTP.BatchSubResponse<Capabilities>)?.body != nil else {
|
||||
SNLog("Open group polling failed due to invalid capability data.")
|
||||
return false
|
||||
}
|
||||
|
@ -291,8 +291,8 @@ extension OpenGroupAPI {
|
|||
return true
|
||||
|
||||
case .roomPollInfo(let roomToken, _):
|
||||
guard (endpointResponse.data as? BatchSubResponse<RoomPollInfo>)?.body != nil else {
|
||||
switch (endpointResponse.data as? BatchSubResponse<RoomPollInfo>)?.code {
|
||||
guard (endpointResponse.data as? HTTP.BatchSubResponse<RoomPollInfo>)?.body != nil else {
|
||||
switch (endpointResponse.data as? HTTP.BatchSubResponse<RoomPollInfo>)?.code {
|
||||
case 404: SNLog("Open group polling failed to retrieve info for unknown room '\(roomToken)'.")
|
||||
default: SNLog("Open group polling failed due to invalid room info data.")
|
||||
}
|
||||
|
@ -303,10 +303,10 @@ extension OpenGroupAPI {
|
|||
|
||||
case .roomMessagesRecent(let roomToken), .roomMessagesBefore(let roomToken, _), .roomMessagesSince(let roomToken, _):
|
||||
guard
|
||||
let responseData: BatchSubResponse<[Failable<Message>]> = endpointResponse.data as? BatchSubResponse<[Failable<Message>]>,
|
||||
let responseData: HTTP.BatchSubResponse<[Failable<Message>]> = endpointResponse.data as? HTTP.BatchSubResponse<[Failable<Message>]>,
|
||||
let responseBody: [Failable<Message>] = responseData.body
|
||||
else {
|
||||
switch (endpointResponse.data as? BatchSubResponse<[Failable<Message>]>)?.code {
|
||||
switch (endpointResponse.data as? HTTP.BatchSubResponse<[Failable<Message>]>)?.code {
|
||||
case 404: SNLog("Open group polling failed to retrieve messages for unknown room '\(roomToken)'.")
|
||||
default: SNLog("Open group polling failed due to invalid messages data.")
|
||||
}
|
||||
|
@ -325,7 +325,7 @@ extension OpenGroupAPI {
|
|||
|
||||
case .inbox, .inboxSince, .outbox, .outboxSince:
|
||||
guard
|
||||
let responseData: BatchSubResponse<[DirectMessage]?> = endpointResponse.data as? BatchSubResponse<[DirectMessage]?>,
|
||||
let responseData: HTTP.BatchSubResponse<[DirectMessage]?> = endpointResponse.data as? HTTP.BatchSubResponse<[DirectMessage]?>,
|
||||
!responseData.failedToParseBody
|
||||
else {
|
||||
SNLog("Open group polling failed due to invalid inbox/outbox data.")
|
||||
|
@ -383,7 +383,7 @@ extension OpenGroupAPI {
|
|||
switch endpoint {
|
||||
case .capabilities:
|
||||
guard
|
||||
let responseData: BatchSubResponse<Capabilities> = endpointResponse.data as? BatchSubResponse<Capabilities>,
|
||||
let responseData: HTTP.BatchSubResponse<Capabilities> = endpointResponse.data as? HTTP.BatchSubResponse<Capabilities>,
|
||||
let responseBody: Capabilities = responseData.body
|
||||
else { return false }
|
||||
|
||||
|
@ -391,7 +391,7 @@ extension OpenGroupAPI {
|
|||
|
||||
case .roomPollInfo(let roomToken, _):
|
||||
guard
|
||||
let responseData: BatchSubResponse<RoomPollInfo> = endpointResponse.data as? BatchSubResponse<RoomPollInfo>,
|
||||
let responseData: HTTP.BatchSubResponse<RoomPollInfo> = endpointResponse.data as? HTTP.BatchSubResponse<RoomPollInfo>,
|
||||
let responseBody: RoomPollInfo = responseData.body
|
||||
else { return false }
|
||||
guard let existingOpenGroup: OpenGroup = currentInfo?.groups.first(where: { $0.roomToken == roomToken }) else {
|
||||
|
@ -428,7 +428,7 @@ extension OpenGroupAPI {
|
|||
switch endpoint {
|
||||
case .capabilities:
|
||||
guard
|
||||
let responseData: BatchSubResponse<Capabilities> = endpointResponse.data as? BatchSubResponse<Capabilities>,
|
||||
let responseData: HTTP.BatchSubResponse<Capabilities> = endpointResponse.data as? HTTP.BatchSubResponse<Capabilities>,
|
||||
let responseBody: Capabilities = responseData.body
|
||||
else { return }
|
||||
|
||||
|
@ -440,7 +440,7 @@ extension OpenGroupAPI {
|
|||
|
||||
case .roomPollInfo(let roomToken, _):
|
||||
guard
|
||||
let responseData: BatchSubResponse<RoomPollInfo> = endpointResponse.data as? BatchSubResponse<RoomPollInfo>,
|
||||
let responseData: HTTP.BatchSubResponse<RoomPollInfo> = endpointResponse.data as? HTTP.BatchSubResponse<RoomPollInfo>,
|
||||
let responseBody: RoomPollInfo = responseData.body
|
||||
else { return }
|
||||
|
||||
|
@ -455,7 +455,7 @@ extension OpenGroupAPI {
|
|||
|
||||
case .roomMessagesRecent(let roomToken), .roomMessagesBefore(let roomToken, _), .roomMessagesSince(let roomToken, _):
|
||||
guard
|
||||
let responseData: BatchSubResponse<[Failable<Message>]> = endpointResponse.data as? BatchSubResponse<[Failable<Message>]>,
|
||||
let responseData: HTTP.BatchSubResponse<[Failable<Message>]> = endpointResponse.data as? HTTP.BatchSubResponse<[Failable<Message>]>,
|
||||
let responseBody: [Failable<Message>] = responseData.body
|
||||
else { return }
|
||||
|
||||
|
@ -469,7 +469,7 @@ extension OpenGroupAPI {
|
|||
|
||||
case .inbox, .inboxSince, .outbox, .outboxSince:
|
||||
guard
|
||||
let responseData: BatchSubResponse<[DirectMessage]?> = endpointResponse.data as? BatchSubResponse<[DirectMessage]?>,
|
||||
let responseData: HTTP.BatchSubResponse<[DirectMessage]?> = endpointResponse.data as? HTTP.BatchSubResponse<[DirectMessage]?>,
|
||||
!responseData.failedToParseBody
|
||||
else { return }
|
||||
|
||||
|
|
|
@ -191,12 +191,12 @@ public class Poller {
|
|||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
|
||||
let pollerName: String = (
|
||||
poller?.pollerName(for: publicKey) ??
|
||||
"poller with public key \(publicKey)"
|
||||
)
|
||||
|
||||
|
||||
// Fetch the messages
|
||||
return SnodeAPI.getMessages(in: namespaces, from: snode, associatedWith: publicKey)
|
||||
.flatMap { namespacedResults -> AnyPublisher<Void, Error> in
|
||||
|
@ -239,8 +239,8 @@ public class Poller {
|
|||
}
|
||||
catch {
|
||||
switch error {
|
||||
// Ignore duplicate & selfSend message errors (and don't bother logging
|
||||
// them as there will be a lot since we each service node duplicates messages)
|
||||
// Ignore duplicate & selfSend message errors (and don't bother logging
|
||||
// them as there will be a lot since we each service node duplicates messages)
|
||||
case DatabaseError.SQLITE_CONSTRAINT_UNIQUE,
|
||||
MessageReceiverError.duplicateMessage,
|
||||
MessageReceiverError.duplicateControlMessage,
|
||||
|
@ -252,9 +252,13 @@ public class Poller {
|
|||
break
|
||||
|
||||
case DatabaseError.SQLITE_ABORT:
|
||||
SNLog("Failed to the database being suspended (running in background with no background task).")
|
||||
// In the background ignore 'SQLITE_ABORT' (it generally means
|
||||
// the BackgroundPoller has timed out
|
||||
if !calledFromBackgroundPoller {
|
||||
SNLog("Failed to the database being suspended (running in background with no background task).")
|
||||
}
|
||||
break
|
||||
|
||||
|
||||
default: SNLog("Failed to deserialize envelope due to error: \(error).")
|
||||
}
|
||||
|
||||
|
@ -265,33 +269,41 @@ public class Poller {
|
|||
.forEach { threadId, threadMessages in
|
||||
messageCount += threadMessages.count
|
||||
|
||||
JobRunner.add(
|
||||
db,
|
||||
job: Job(
|
||||
variant: .messageReceive,
|
||||
behaviour: .runOnce,
|
||||
threadId: threadId,
|
||||
details: MessageReceiveJob.Details(
|
||||
messages: threadMessages.map { $0.messageInfo },
|
||||
calledFromBackgroundPoller: false
|
||||
)
|
||||
let jobToRun: Job? = Job(
|
||||
variant: .messageReceive,
|
||||
behaviour: .runOnce,
|
||||
threadId: threadId,
|
||||
details: MessageReceiveJob.Details(
|
||||
messages: threadMessages.map { $0.messageInfo },
|
||||
calledFromBackgroundPoller: calledFromBackgroundPoller
|
||||
)
|
||||
)
|
||||
jobsToRun = jobsToRun.appending(jobToRun)
|
||||
|
||||
// If we are force-polling then add to the JobRunner so they are
|
||||
// persistent and will retry on the next app run if they fail but
|
||||
// don't let them auto-start
|
||||
JobRunner.add(db, job: jobToRun, canStartJob: !calledFromBackgroundPoller)
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up message hashes and add some logs about the poll results
|
||||
if allMessagesCount == 0 && !hadValidHashUpdate {
|
||||
if !calledFromBackgroundPoller {
|
||||
SNLog("Received \(allMessagesCount) new message\(allMessagesCount == 1 ? "" : "s"), all duplicates - marking the hash we polled with as invalid")
|
||||
}
|
||||
|
||||
if messageCount == 0 && !hadValidHashUpdate, let lastHash: String = lastHash {
|
||||
SNLog("Received \(messages.count) new message\(messages.count == 1 ? "" : "s"), all duplicates - marking the hash we polled with as invalid")
|
||||
|
||||
// Update the cached validity of the messages
|
||||
try SnodeReceivedMessageInfo.handlePotentialDeletedOrInvalidHash(
|
||||
db,
|
||||
potentiallyInvalidHashes: [lastHash],
|
||||
otherKnownValidHashes: messages.map { $0.info.hash }
|
||||
)
|
||||
}
|
||||
else {
|
||||
SNLog("Received \(messageCount) new message\(messageCount == 1 ? "" : "s") (duplicates: \(messages.count - messageCount))")
|
||||
}
|
||||
// Update the cached validity of the messages
|
||||
try SnodeReceivedMessageInfo.handlePotentialDeletedOrInvalidHash(
|
||||
db,
|
||||
potentiallyInvalidHashes: lastHashes,
|
||||
otherKnownValidHashes: namespacedResults
|
||||
.compactMap { $0.value.data?.messages.map { $0.info.hash } }
|
||||
.reduce([], +)
|
||||
)
|
||||
}
|
||||
else if !calledFromBackgroundPoller {
|
||||
SNLog("Received \(messageCount) new message\(messageCount == 1 ? "" : "s") in \(pollerName) (duplicates: \(allMessagesCount - messageCount))")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -5,23 +5,7 @@ import SessionUtilitiesKit
|
|||
|
||||
// MARK: - Decoding
|
||||
|
||||
extension Dependencies {
|
||||
static let userInfoKey: CodingUserInfoKey = CodingUserInfoKey(rawValue: "io.oxen.dependencies.codingOptions")!
|
||||
}
|
||||
|
||||
public extension Data {
|
||||
func decoded<T: Decodable>(as type: T.Type, using dependencies: Dependencies = Dependencies()) throws -> T {
|
||||
do {
|
||||
let decoder: JSONDecoder = JSONDecoder()
|
||||
decoder.userInfo = [ Dependencies.userInfoKey: dependencies ]
|
||||
|
||||
return try decoder.decode(type, from: self)
|
||||
}
|
||||
catch {
|
||||
throw HTTP.Error.parsingFailed
|
||||
}
|
||||
}
|
||||
|
||||
func removePadding() -> Data {
|
||||
let bytes: [UInt8] = self.bytes
|
||||
var paddingStart: Int = self.count
|
||||
|
|
|
@ -78,6 +78,13 @@ public struct ProfileManager {
|
|||
return data
|
||||
}
|
||||
|
||||
public static func hasProfileImageData(with fileName: String?) -> Bool {
|
||||
guard let fileName: String = fileName, !fileName.isEmpty else { return false }
|
||||
|
||||
return FileManager.default
|
||||
.fileExists(atPath: ProfileManager.profileAvatarFilepath(filename: fileName))
|
||||
}
|
||||
|
||||
private static func loadProfileData(with fileName: String) -> Data? {
|
||||
let filePath: String = ProfileManager.profileAvatarFilepath(filename: fileName)
|
||||
|
||||
|
@ -228,7 +235,7 @@ public struct ProfileManager {
|
|||
return
|
||||
}
|
||||
|
||||
guard let decryptedData: Data = decryptProfileData(data: data, key: profileKeyAtStart) else {
|
||||
guard let decryptedData: Data = decryptData(data: data, key: profileKeyAtStart) else {
|
||||
OWSLogger.warn("Avatar data for \(profile.id) could not be decrypted.")
|
||||
return
|
||||
}
|
||||
|
@ -388,38 +395,22 @@ public struct ProfileManager {
|
|||
return
|
||||
}
|
||||
|
||||
// If we have no image then we should succeed (database changes happen in the callback)
|
||||
guard let data: Data = avatarImageData else {
|
||||
// If we have no image then we need to make sure to remove it from the profile
|
||||
Storage.shared.writeAsync { db in
|
||||
let existingProfile: Profile = Profile.fetchOrCreateCurrentUser(db)
|
||||
|
||||
OWSLogger.verbose(existingProfile.profilePictureUrl != nil ?
|
||||
"Updating local profile on service with cleared avatar." :
|
||||
"Updating local profile on service with no avatar."
|
||||
)
|
||||
|
||||
let updatedProfile: Profile = try existingProfile
|
||||
.with(
|
||||
name: profileName,
|
||||
profilePictureUrl: nil,
|
||||
profilePictureFileName: nil,
|
||||
profileEncryptionKey: (existingProfile.profilePictureUrl != nil ?
|
||||
.update(newProfileKey) :
|
||||
.existing
|
||||
)
|
||||
)
|
||||
.saved(db)
|
||||
|
||||
// Remove any cached avatar image value
|
||||
if let fileName: String = existingProfile.profilePictureFileName {
|
||||
profileAvatarCache.mutate { $0[fileName] = nil }
|
||||
// Remove any cached avatar image value
|
||||
let maybeExistingFileName: String? = Storage.shared
|
||||
.read { db in
|
||||
try Profile
|
||||
.select(.profilePictureFileName)
|
||||
.asRequest(of: String.self)
|
||||
.fetchOne(db)
|
||||
}
|
||||
|
||||
SNLog("Successfully updated service with profile.")
|
||||
|
||||
try success?(db, updatedProfile)
|
||||
|
||||
if let fileName: String = maybeExistingFileName {
|
||||
profileAvatarCache.mutate { $0[fileName] = nil }
|
||||
}
|
||||
return
|
||||
|
||||
return success(nil, newProfileKey)
|
||||
}
|
||||
|
||||
// If we have a new avatar image, we must first:
|
||||
|
@ -447,7 +438,7 @@ public struct ProfileManager {
|
|||
}
|
||||
|
||||
// Encrypt the avatar for upload
|
||||
guard let encryptedAvatarData: Data = encryptProfileData(data: data, key: newProfileKey) else {
|
||||
guard let encryptedAvatarData: Data = encryptData(data: data, key: newProfileKey) else {
|
||||
SNLog("Updating service with profile failed.")
|
||||
failure?(.avatarEncryptionFailed)
|
||||
return
|
||||
|
|
|
@ -7,7 +7,7 @@ import SignalUtilitiesKit
|
|||
import SessionUIKit
|
||||
import SignalCoreKit
|
||||
|
||||
final class ShareVC: UINavigationController, ShareViewDelegate {
|
||||
final class ShareNavController: UINavigationController, ShareViewDelegate {
|
||||
private var areVersionMigrationsComplete = false
|
||||
public static var attachmentPrepPublisher: AnyPublisher<[SignalAttachment], Error>?
|
||||
|
||||
|
@ -183,7 +183,7 @@ final class ShareVC: UINavigationController, ShareViewDelegate {
|
|||
|
||||
private func showMainContent() {
|
||||
let threadPickerVC: ThreadPickerVC = ThreadPickerVC()
|
||||
threadPickerVC.shareVC = self
|
||||
threadPickerVC.shareNavController = self
|
||||
|
||||
setViewControllers([ threadPickerVC ], animated: false)
|
||||
|
||||
|
@ -427,7 +427,7 @@ final class ShareVC: UINavigationController, ShareViewDelegate {
|
|||
// * UTIs aren't very descriptive (there are far more MIME types than UTI types)
|
||||
// so in the case of file attachments we try to refine the attachment type
|
||||
// using the file extension.
|
||||
guard let srcUtiType = ShareVC.utiType(itemProvider: itemProvider) else {
|
||||
guard let srcUtiType = ShareNavController.utiType(itemProvider: itemProvider) else {
|
||||
let error = ShareViewControllerError.unsupportedMedia
|
||||
return Fail(error: error)
|
||||
.eraseToAnyPublisher()
|
||||
|
@ -613,7 +613,7 @@ final class ShareVC: UINavigationController, ShareViewDelegate {
|
|||
|
||||
Logger.debug("building DataSource with url: \(url), utiType: \(utiType)")
|
||||
|
||||
guard let dataSource = ShareVC.createDataSource(utiType: utiType, url: url, customFileName: loadedItem.customFileName) else {
|
||||
guard let dataSource = ShareNavController.createDataSource(utiType: utiType, url: url, customFileName: loadedItem.customFileName) else {
|
||||
let error = ShareViewControllerError.assertionError(description: "Unable to read attachment data")
|
||||
return Fail(error: error)
|
||||
.eraseToAnyPublisher()
|
|
@ -92,12 +92,10 @@ final class SimplifiedConversationCell: UITableViewCell {
|
|||
accentLineView.alpha = (cellViewModel.threadIsBlocked == true ? 1 : 0)
|
||||
profilePictureView.update(
|
||||
publicKey: cellViewModel.threadId,
|
||||
profile: cellViewModel.profile,
|
||||
additionalProfile: cellViewModel.additionalProfile,
|
||||
threadVariant: cellViewModel.threadVariant,
|
||||
openGroupProfilePictureData: cellViewModel.openGroupProfilePictureData,
|
||||
useFallbackPicture: (cellViewModel.threadVariant == .openGroup && cellViewModel.openGroupProfilePictureData == nil),
|
||||
showMultiAvatarForClosedGroup: true
|
||||
customImageData: cellViewModel.openGroupProfilePictureData,
|
||||
profile: cellViewModel.profile,
|
||||
additionalProfile: cellViewModel.additionalProfile
|
||||
)
|
||||
displayNameLabel.text = cellViewModel.displayName
|
||||
}
|
||||
|
|
|
@ -13,7 +13,7 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView
|
|||
private var dataChangeObservable: DatabaseCancellable?
|
||||
private var hasLoadedInitialData: Bool = false
|
||||
|
||||
var shareVC: ShareVC?
|
||||
var shareNavController: ShareNavController?
|
||||
|
||||
// MARK: - Intialization
|
||||
|
||||
|
@ -182,9 +182,9 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView
|
|||
messageText
|
||||
)
|
||||
|
||||
shareVC?.dismiss(animated: true, completion: nil)
|
||||
shareNavController?.dismiss(animated: true, completion: nil)
|
||||
|
||||
ModalActivityIndicatorViewController.present(fromViewController: shareVC!, canCancel: false, message: "vc_share_sending_message".localized()) { activityIndicator in
|
||||
ModalActivityIndicatorViewController.present(fromViewController: shareNavController!, canCancel: false, message: "vc_share_sending_message".localized()) { activityIndicator in
|
||||
// Resume database
|
||||
NotificationCenter.default.post(name: Database.resumeNotification, object: self)
|
||||
|
||||
|
|
|
@ -60,7 +60,7 @@ extension Snode {
|
|||
}
|
||||
catch {
|
||||
SNLog("Failed to parse snode: \(error.localizedDescription).")
|
||||
throw HTTP.Error.invalidJSON
|
||||
throw HTTPError.invalidJSON
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -52,18 +52,18 @@ public struct SnodeReceivedMessageInfo: Codable, FetchableRecord, MutablePersist
|
|||
// MARK: - Convenience
|
||||
|
||||
public extension SnodeReceivedMessageInfo {
|
||||
private static func key(for snode: Snode, publicKey: String, namespace: Int) -> String {
|
||||
guard namespace != SnodeAPI.defaultNamespace else {
|
||||
private static func key(for snode: Snode, publicKey: String, namespace: SnodeAPI.Namespace) -> String {
|
||||
guard namespace != .default else {
|
||||
return "\(snode.address):\(snode.port).\(publicKey)"
|
||||
}
|
||||
|
||||
return "\(snode.address):\(snode.port).\(publicKey).\(namespace)"
|
||||
return "\(snode.address):\(snode.port).\(publicKey).\(namespace.rawValue)"
|
||||
}
|
||||
|
||||
init(
|
||||
snode: Snode,
|
||||
publicKey: String,
|
||||
namespace: Int,
|
||||
namespace: SnodeAPI.Namespace,
|
||||
hash: String,
|
||||
expirationDateMs: Int64?
|
||||
) {
|
||||
|
@ -76,15 +76,15 @@ public extension SnodeReceivedMessageInfo {
|
|||
// MARK: - GRDB Interactions
|
||||
|
||||
public extension SnodeReceivedMessageInfo {
|
||||
static func pruneExpiredMessageHashInfo(for snode: Snode, namespace: Int, associatedWith publicKey: String) {
|
||||
// Delete any expired SnodeReceivedMessageInfo values associated to a specific node (even though
|
||||
// this runs very quickly we fetch the rowIds we want to delete from a 'read' call to avoid
|
||||
// blocking the write queue since this method is called very frequently)
|
||||
static func pruneExpiredMessageHashInfo(for snode: Snode, namespace: SnodeAPI.Namespace, associatedWith publicKey: String) {
|
||||
// Delete any expired SnodeReceivedMessageInfo values associated to a specific node (even
|
||||
// though this runs very quickly we fetch the rowIds we want to delete from a 'read' call
|
||||
// to avoid blocking the write queue since this method is called very frequently)
|
||||
let rowIds: [Int64] = Storage.shared
|
||||
.read { db in
|
||||
// Only prune the hashes if new hashes exist for this Snode (if they don't then we don't want
|
||||
// to clear out the legacy hashes)
|
||||
let hasNonLegacyHash: Bool = try SnodeReceivedMessageInfo
|
||||
// Only prune the hashes if new hashes exist for this Snode (if they don't then
|
||||
// we don't want to clear out the legacy hashes)
|
||||
let hasNonLegacyHash: Bool = SnodeReceivedMessageInfo
|
||||
.filter(SnodeReceivedMessageInfo.Columns.key == key(for: snode, publicKey: publicKey, namespace: namespace))
|
||||
.isNotEmpty(db)
|
||||
|
||||
|
@ -111,10 +111,10 @@ public extension SnodeReceivedMessageInfo {
|
|||
|
||||
/// This method fetches the last non-expired hash from the database for message retrieval
|
||||
///
|
||||
/// **Note:** This method uses a `write` instead of a read because there is a single write queue for the database and it's very common for
|
||||
/// this method to be called after the hash value has been updated but before the various `read` threads have been updated, resulting in a
|
||||
/// pointless fetch for data the app has already received
|
||||
static func fetchLastNotExpired(for snode: Snode, namespace: Int, associatedWith publicKey: String) -> SnodeReceivedMessageInfo? {
|
||||
/// **Note:** This method uses a `write` instead of a read because there is a single write queue for the database and it's
|
||||
/// very common for this method to be called after the hash value has been updated but before the various `read` threads
|
||||
/// have been updated, resulting in a pointless fetch for data the app has already received
|
||||
static func fetchLastNotExpired(for snode: Snode, namespace: SnodeAPI.Namespace, associatedWith publicKey: String) -> SnodeReceivedMessageInfo? {
|
||||
return Storage.shared.read { db in
|
||||
let nonLegacyHash: SnodeReceivedMessageInfo? = try SnodeReceivedMessageInfo
|
||||
.filter(
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
import Foundation
|
||||
import Combine
|
||||
import GRDB
|
||||
import SignalCoreKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
public enum GetSnodePoolJob: JobExecutor {
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue