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(
|
profilePictureView.update(
|
||||||
publicKey: call.sessionId,
|
publicKey: call.sessionId,
|
||||||
|
threadVariant: .contact,
|
||||||
|
customImageData: nil,
|
||||||
profile: Profile.fetchOrCreate(id: call.sessionId),
|
profile: Profile.fetchOrCreate(id: call.sessionId),
|
||||||
threadVariant: .contact
|
additionalProfile: nil
|
||||||
)
|
)
|
||||||
displayNameLabel.text = call.contactName
|
displayNameLabel.text = call.contactName
|
||||||
|
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import Combine
|
||||||
import GRDB
|
import GRDB
|
||||||
import DifferenceKit
|
import DifferenceKit
|
||||||
import PromiseKit
|
|
||||||
import SessionUIKit
|
import SessionUIKit
|
||||||
import SessionMessagingKit
|
import SessionMessagingKit
|
||||||
import SignalUtilitiesKit
|
import SignalUtilitiesKit
|
||||||
|
@ -220,7 +220,8 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat
|
||||||
cell.update(
|
cell.update(
|
||||||
with: SessionCell.Info(
|
with: SessionCell.Info(
|
||||||
id: displayInfo,
|
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: (
|
title: (
|
||||||
displayInfo.profile?.displayName() ??
|
displayInfo.profile?.displayName() ??
|
||||||
Profile.truncated(id: displayInfo.profileId, threadVariant: .contact)
|
Profile.truncated(id: displayInfo.profileId, threadVariant: .contact)
|
||||||
|
@ -231,10 +232,9 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat
|
||||||
.withRenderingMode(.alwaysTemplate),
|
.withRenderingMode(.alwaysTemplate),
|
||||||
customTint: .textSecondary
|
customTint: .textSecondary
|
||||||
)
|
)
|
||||||
)
|
),
|
||||||
),
|
styling: SessionCell.StyleInfo(backgroundStyle: .edgeToEdge)
|
||||||
style: .edgeToEdge,
|
)
|
||||||
position: Position.with(indexPath.row, count: membersAndZombies.count)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return cell
|
return cell
|
||||||
|
@ -449,7 +449,7 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat
|
||||||
|
|
||||||
ModalActivityIndicatorViewController.present(fromViewController: navigationController) { _ in
|
ModalActivityIndicatorViewController.present(fromViewController: navigationController) { _ in
|
||||||
Storage.shared
|
Storage.shared
|
||||||
.writeAsync { db in
|
.writePublisherFlatMap { db -> AnyPublisher<Void, Error> in
|
||||||
if !updatedMemberIds.contains(userPublicKey) {
|
if !updatedMemberIds.contains(userPublicKey) {
|
||||||
return try MessageSender.leave(db, groupPublicKey: threadId)
|
return try MessageSender.leave(db, groupPublicKey: threadId)
|
||||||
}
|
}
|
||||||
|
@ -461,15 +461,20 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat
|
||||||
name: updatedName
|
name: updatedName
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.done(on: DispatchQueue.main) { [weak self] in
|
.sinkUntilComplete(
|
||||||
self?.dismiss(animated: true, completion: nil) // Dismiss the loader
|
receiveCompletion: { [weak self] result in
|
||||||
popToConversationVC(self)
|
self?.dismiss(animated: true, completion: nil) // Dismiss the loader
|
||||||
}
|
|
||||||
.catch(on: DispatchQueue.main) { [weak self] error in
|
switch result {
|
||||||
self?.dismiss(animated: true, completion: nil) // Dismiss the loader
|
case .finished: popToConversationVC(self)
|
||||||
self?.showError(title: "GROUP_UPDATE_ERROR_TITLE".localized(), message: error.localizedDescription)
|
case .failure(let error):
|
||||||
}
|
self?.showError(
|
||||||
.retainUntilComplete()
|
title: "GROUP_UPDATE_ERROR_TITLE".localized(),
|
||||||
|
message: error.localizedDescription
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -205,15 +205,17 @@ final class NewClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate
|
||||||
cell.update(
|
cell.update(
|
||||||
with: SessionCell.Info(
|
with: SessionCell.Info(
|
||||||
id: profile,
|
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(),
|
title: profile.displayName(),
|
||||||
rightAccessory: .radio(isSelected: { [weak self] in
|
rightAccessory: .radio(isSelected: { [weak self] in
|
||||||
self?.selectedContacts.contains(profile.id) == true
|
self?.selectedContacts.contains(profile.id) == true
|
||||||
}),
|
}),
|
||||||
accessibilityIdentifier: "Contact"
|
styling: SessionCell.StyleInfo(backgroundStyle: .edgeToEdge),
|
||||||
),
|
accessibility: SessionCell.Accessibility(
|
||||||
style: .edgeToEdge,
|
identifier: "Contact"
|
||||||
position: Position.with(indexPath.row, count: data[indexPath.section].elements.count)
|
)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
return cell
|
return cell
|
||||||
|
|
|
@ -1872,7 +1872,7 @@ extension ConversationVC:
|
||||||
deleteRemotely(
|
deleteRemotely(
|
||||||
from: self,
|
from: self,
|
||||||
request: SnodeAPI
|
request: SnodeAPI
|
||||||
.deleteMessage(
|
.deleteMessages(
|
||||||
publicKey: threadId,
|
publicKey: threadId,
|
||||||
serverHashes: [serverHash]
|
serverHashes: [serverHash]
|
||||||
)
|
)
|
||||||
|
@ -2328,10 +2328,11 @@ extension ConversationVC {
|
||||||
)
|
)
|
||||||
.save(db)
|
.save(db)
|
||||||
|
|
||||||
// Send a sync message with the details of the contact
|
|
||||||
|
// Update the config with the approved contact
|
||||||
try MessageSender
|
try MessageSender
|
||||||
.syncConfiguration(db, forceSyncNow: true)
|
.syncConfiguration(db, forceSyncNow: true)
|
||||||
.retainUntilComplete()
|
.sinkUntilComplete()
|
||||||
},
|
},
|
||||||
completion: { _, _ in updateNavigationBackStack() }
|
completion: { _, _ in updateNavigationBackStack() }
|
||||||
)
|
)
|
||||||
|
@ -2347,79 +2348,31 @@ extension ConversationVC {
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func deleteMessageRequest() {
|
@objc func deleteMessageRequest() {
|
||||||
guard self.viewModel.threadData.threadVariant == .contact else { return }
|
MessageRequestsViewModel.deleteMessageRequest(
|
||||||
|
threadId: self.viewModel.threadData.threadId,
|
||||||
let threadId: String = self.viewModel.threadData.threadId
|
threadVariant: self.viewModel.threadData.threadVariant,
|
||||||
let alertVC: UIAlertController = UIAlertController(
|
viewController: self
|
||||||
title: "MESSAGE_REQUESTS_DELETE_CONFIRMATION_ACTON".localized(),
|
) { [weak self] in
|
||||||
message: nil,
|
self?.stopObservingChanges()
|
||||||
preferredStyle: .actionSheet
|
|
||||||
)
|
DispatchQueue.main.async {
|
||||||
alertVC.addAction(UIAlertAction(title: "TXT_DELETE_TITLE".localized(), style: .destructive) { _ in
|
self?.navigationController?.popViewController(animated: true)
|
||||||
// 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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func block() {
|
@objc func blockMessageRequest() {
|
||||||
guard self.viewModel.threadData.threadVariant == .contact else { return }
|
MessageRequestsViewModel.blockMessageRequest(
|
||||||
|
threadId: self.viewModel.threadData.threadId,
|
||||||
let threadId: String = self.viewModel.threadData.threadId
|
threadVariant: self.viewModel.threadData.threadVariant,
|
||||||
let alertVC: UIAlertController = UIAlertController(
|
viewController: self
|
||||||
title: "MESSAGE_REQUESTS_BLOCK_CONFIRMATION_ACTON".localized(),
|
) { [weak self] in
|
||||||
message: nil,
|
self?.stopObservingChanges()
|
||||||
preferredStyle: .actionSheet
|
|
||||||
)
|
DispatchQueue.main.async {
|
||||||
alertVC.addAction(UIAlertAction(title: "BLOCK_LIST_BLOCK_BUTTON".localized(), style: .destructive) { _ in
|
self?.navigationController?.popViewController(animated: true)
|
||||||
// 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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -27,10 +27,6 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
|
||||||
|
|
||||||
var focusedInteractionId: Int64?
|
var focusedInteractionId: Int64?
|
||||||
var shouldHighlightNextScrollToInteraction: Bool = false
|
var shouldHighlightNextScrollToInteraction: Bool = false
|
||||||
var scrollButtonBottomConstraint: NSLayoutConstraint?
|
|
||||||
var scrollButtonMessageRequestsBottomConstraint: NSLayoutConstraint?
|
|
||||||
var scrollButtonPendingMessageRequestInfoBottomConstraint: NSLayoutConstraint?
|
|
||||||
var messageRequestsViewBotomConstraint: NSLayoutConstraint?
|
|
||||||
|
|
||||||
// Search
|
// Search
|
||||||
var isShowingSearchUI = false
|
var isShowingSearchUI = false
|
||||||
|
@ -40,8 +36,6 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
|
||||||
var audioRecorder: AVAudioRecorder?
|
var audioRecorder: AVAudioRecorder?
|
||||||
var audioTimer: Timer?
|
var audioTimer: Timer?
|
||||||
|
|
||||||
private var searchBarWidth: NSLayoutConstraint?
|
|
||||||
|
|
||||||
// Context menu
|
// Context menu
|
||||||
var contextMenuWindow: ContextMenuWindow?
|
var contextMenuWindow: ContextMenuWindow?
|
||||||
var contextMenuVC: ContextMenuVC?
|
var contextMenuVC: ContextMenuVC?
|
||||||
|
@ -129,6 +123,12 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
|
||||||
|
|
||||||
// MARK: - UI
|
// MARK: - UI
|
||||||
|
|
||||||
|
var scrollButtonBottomConstraint: NSLayoutConstraint?
|
||||||
|
var scrollButtonMessageRequestsBottomConstraint: NSLayoutConstraint?
|
||||||
|
var scrollButtonPendingMessageRequestInfoBottomConstraint: NSLayoutConstraint?
|
||||||
|
var messageRequestsViewBotomConstraint: NSLayoutConstraint?
|
||||||
|
var messageRequestDescriptionLabelBottomConstraint: NSLayoutConstraint?
|
||||||
|
|
||||||
lazy var titleView: ConversationTitleView = {
|
lazy var titleView: ConversationTitleView = {
|
||||||
let result: ConversationTitleView = ConversationTitleView()
|
let result: ConversationTitleView = ConversationTitleView()
|
||||||
let tapGestureRecognizer = UITapGestureRecognizer(
|
let tapGestureRecognizer = UITapGestureRecognizer(
|
||||||
|
@ -221,11 +221,22 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
|
||||||
}()
|
}()
|
||||||
|
|
||||||
lazy var scrollButton: ScrollToBottomButton = ScrollToBottomButton(delegate: self)
|
lazy var scrollButton: ScrollToBottomButton = ScrollToBottomButton(delegate: self)
|
||||||
|
|
||||||
lazy var messageRequestView: UIView = {
|
lazy var messageRequestBackgroundView: UIView = {
|
||||||
let result: UIView = UIView()
|
let result: UIView = UIView()
|
||||||
result.translatesAutoresizingMaskIntoConstraints = false
|
result.translatesAutoresizingMaskIntoConstraints = false
|
||||||
result.themeBackgroundColor = .backgroundPrimary
|
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 = (
|
result.isHidden = (
|
||||||
self.viewModel.threadData.threadIsMessageRequest == false ||
|
self.viewModel.threadData.threadIsMessageRequest == false ||
|
||||||
self.viewModel.threadData.threadRequiresApproval == true
|
self.viewModel.threadData.threadRequiresApproval == true
|
||||||
|
@ -233,18 +244,40 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
|
||||||
|
|
||||||
return result
|
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()
|
let result: UILabel = UILabel()
|
||||||
result.translatesAutoresizingMaskIntoConstraints = false
|
result.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
result.setContentCompressionResistancePriority(.required, for: .vertical)
|
||||||
result.font = UIFont.systemFont(ofSize: 12)
|
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.themeTextColor = .textSecondary
|
||||||
result.textAlignment = .center
|
result.textAlignment = .center
|
||||||
result.numberOfLines = 0
|
result.numberOfLines = 0
|
||||||
|
|
||||||
return result
|
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 = {
|
private lazy var messageRequestAcceptButton: UIButton = {
|
||||||
let result: SessionButton = SessionButton(style: .bordered, size: .medium)
|
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.titleLabel?.font = UIFont.boldSystemFont(ofSize: 16)
|
||||||
result.setTitle("TXT_BLOCK_USER_TITLE".localized(), for: .normal)
|
result.setTitle("TXT_BLOCK_USER_TITLE".localized(), for: .normal)
|
||||||
result.setThemeTitleColor(.danger, 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
|
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
|
// MARK: - Settings
|
||||||
|
|
||||||
|
@ -352,46 +369,32 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
|
||||||
view.addSubview(scrollButton)
|
view.addSubview(scrollButton)
|
||||||
view.addSubview(messageRequestBackgroundView)
|
view.addSubview(messageRequestBackgroundView)
|
||||||
view.addSubview(messageRequestStackView)
|
view.addSubview(messageRequestStackView)
|
||||||
view.addSubview(pendingMessageRequestExplanationLabel)
|
|
||||||
|
|
||||||
messageRequestView.addSubview(messageRequestBlockButton)
|
messageRequestStackView.addArrangedSubview(messageRequestBlockButton)
|
||||||
messageRequestView.addSubview(messageRequestDescriptionLabel)
|
messageRequestStackView.addArrangedSubview(messageRequestDescriptionContainerView)
|
||||||
messageRequestView.addSubview(messageRequestAcceptButton)
|
messageRequestStackView.addArrangedSubview(messageRequestActionStackView)
|
||||||
messageRequestView.addSubview(messageRequestDeleteButton)
|
messageRequestDescriptionContainerView.addSubview(messageRequestDescriptionLabel)
|
||||||
|
messageRequestActionStackView.addArrangedSubview(messageRequestAcceptButton)
|
||||||
|
messageRequestActionStackView.addArrangedSubview(messageRequestDeleteButton)
|
||||||
|
|
||||||
scrollButton.pin(.right, to: .right, of: view, withInset: -20)
|
scrollButton.pin(.trailing, to: .trailing, of: view, withInset: -20)
|
||||||
messageRequestView.pin(.left, to: .left, of: view)
|
messageRequestStackView.pin(.leading, to: .leading, of: view, withInset: 16)
|
||||||
messageRequestView.pin(.right, to: .right, of: view)
|
messageRequestStackView.pin(.trailing, to: .trailing, of: view, withInset: -16)
|
||||||
self.messageRequestsViewBotomConstraint = messageRequestView.pin(.bottom, to: .bottom, 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 = 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.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.scrollButtonMessageRequestsBottomConstraint = scrollButton.pin(.bottom, to: .top, of: messageRequestStackView, withInset: -4)
|
||||||
self.scrollButtonPendingMessageRequestInfoBottomConstraint = scrollButton.pin(.bottom, to: .top, of: pendingMessageRequestExplanationLabel, withInset: -16)
|
|
||||||
|
|
||||||
messageRequestBlockButton.pin(.top, to: .top, of: messageRequestView, withInset: 10)
|
messageRequestDescriptionLabel.pin(.top, to: .top, of: messageRequestDescriptionContainerView, withInset: 4)
|
||||||
messageRequestBlockButton.center(.horizontal, in: messageRequestView)
|
messageRequestDescriptionLabel.pin(.leading, to: .leading, of: messageRequestDescriptionContainerView, withInset: 20)
|
||||||
|
messageRequestDescriptionLabel.pin(.trailing, to: .trailing, of: messageRequestDescriptionContainerView, withInset: -20)
|
||||||
messageRequestDescriptionLabel.pin(.top, to: .bottom, of: messageRequestBlockButton, withInset: 5)
|
self.messageRequestDescriptionLabelBottomConstraint = messageRequestDescriptionLabel.pin(.bottom, to: .bottom, of: messageRequestDescriptionContainerView, withInset: -20)
|
||||||
messageRequestDescriptionLabel.pin(.left, to: .left, of: messageRequestView, withInset: 40)
|
messageRequestActionStackView.pin(.top, to: .bottom, of: messageRequestDescriptionContainerView)
|
||||||
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)
|
|
||||||
messageRequestDeleteButton.set(.width, to: .width, of: messageRequestAcceptButton)
|
messageRequestDeleteButton.set(.width, to: .width, of: messageRequestAcceptButton)
|
||||||
messageRequestBackgroundView.pin(.top, to: .top, of: messageRequestStackView)
|
messageRequestBackgroundView.pin(.top, to: .top, of: messageRequestStackView)
|
||||||
messageRequestBackgroundView.pin(.leading, to: .leading, of: view)
|
messageRequestBackgroundView.pin(.leading, to: .leading, of: view)
|
||||||
messageRequestBackgroundView.pin(.trailing, to: .trailing, of: view)
|
messageRequestBackgroundView.pin(.trailing, to: .trailing, of: view)
|
||||||
messageRequestBackgroundView.pin(.bottom, to: .bottom, 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
|
// Unread count view
|
||||||
view.addSubview(unreadCountView)
|
view.addSubview(unreadCountView)
|
||||||
|
@ -505,12 +508,6 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
|
||||||
stopObservingChanges()
|
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
|
// MARK: - Updating
|
||||||
|
|
||||||
private func startObservingChanges(didReturnFromBackground: Bool = false) {
|
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
|
// Stop observing database changes
|
||||||
dataChangeObservable?.cancel()
|
dataChangeObservable?.cancel()
|
||||||
self.viewModel.onInteractionChange = nil
|
self.viewModel.onInteractionChange = nil
|
||||||
|
@ -619,6 +616,7 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
|
||||||
|
|
||||||
if
|
if
|
||||||
initialLoad ||
|
initialLoad ||
|
||||||
|
viewModel.threadData.threadVariant != updatedThreadData.threadVariant ||
|
||||||
viewModel.threadData.threadRequiresApproval != updatedThreadData.threadRequiresApproval ||
|
viewModel.threadData.threadRequiresApproval != updatedThreadData.threadRequiresApproval ||
|
||||||
viewModel.threadData.threadIsMessageRequest != updatedThreadData.threadIsMessageRequest ||
|
viewModel.threadData.threadIsMessageRequest != updatedThreadData.threadIsMessageRequest ||
|
||||||
viewModel.threadData.profile != updatedThreadData.profile
|
viewModel.threadData.profile != updatedThreadData.profile
|
||||||
|
@ -628,47 +626,33 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
|
||||||
let messageRequestsViewWasVisible: Bool = (
|
let messageRequestsViewWasVisible: Bool = (
|
||||||
messageRequestStackView.isHidden == false
|
messageRequestStackView.isHidden == false
|
||||||
)
|
)
|
||||||
let pendingMessageRequestInfoWasVisible: Bool = (
|
|
||||||
pendingMessageRequestExplanationLabel.isHidden == false
|
|
||||||
)
|
|
||||||
|
|
||||||
UIView.animate(withDuration: 0.3) { [weak self] in
|
UIView.animate(withDuration: 0.3) { [weak self] in
|
||||||
self?.messageRequestView.isHidden = (
|
self?.messageRequestBlockButton.isHidden = (
|
||||||
updatedThreadData.threadIsMessageRequest == false ||
|
self?.viewModel.threadData.threadVariant != .contact ||
|
||||||
updatedThreadData.threadRequiresApproval == true
|
updatedThreadData.threadRequiresApproval == true
|
||||||
)
|
)
|
||||||
self?.messageRequestBackgroundView.isHidden = (self?.messageRequestStackView.isHidden == true)
|
self?.messageRequestActionStackView.isHidden = (
|
||||||
self?.pendingMessageRequestExplanationLabel.isHidden = (
|
updatedThreadData.threadRequiresApproval == true
|
||||||
self?.messageRequestStackView.isHidden == false ||
|
)
|
||||||
|
self?.messageRequestStackView.isHidden = (
|
||||||
|
updatedThreadData.threadIsMessageRequest == false &&
|
||||||
updatedThreadData.threadRequiresApproval == false
|
updatedThreadData.threadRequiresApproval == false
|
||||||
)
|
)
|
||||||
|
self?.messageRequestBackgroundView.isHidden = (self?.messageRequestStackView.isHidden == true)
|
||||||
|
self?.messageRequestDescriptionLabelBottomConstraint?.constant = (updatedThreadData.threadRequiresApproval == true ? -4 : -20)
|
||||||
|
|
||||||
self?.scrollButtonMessageRequestsBottomConstraint?.isActive = (
|
self?.scrollButtonMessageRequestsBottomConstraint?.isActive = (
|
||||||
self?.messageRequestStackView.isHidden == false
|
self?.messageRequestStackView.isHidden == false
|
||||||
)
|
)
|
||||||
self?.scrollButtonPendingMessageRequestInfoBottomConstraint?.isActive = (
|
|
||||||
self?.scrollButtonPendingMessageRequestInfoBottomConstraint?.isActive == false &&
|
|
||||||
self?.pendingMessageRequestExplanationLabel.isHidden == false
|
|
||||||
)
|
|
||||||
self?.scrollButtonBottomConstraint?.isActive = (
|
self?.scrollButtonBottomConstraint?.isActive = (
|
||||||
self?.scrollButtonMessageRequestsBottomConstraint?.isActive == false &&
|
self?.scrollButtonMessageRequestsBottomConstraint?.isActive == false
|
||||||
self?.scrollButtonPendingMessageRequestInfoBottomConstraint?.isActive == false
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Update the table content inset and offset to account for
|
// Update the table content inset and offset to account for
|
||||||
// the dissapearance of the messageRequestsView
|
// the dissapearance of the messageRequestsView
|
||||||
if messageRequestsViewWasVisible {
|
if messageRequestsViewWasVisible != (self?.messageRequestStackView.isHidden == false) {
|
||||||
let messageRequestsOffset: CGFloat = ((self?.messageRequestView.bounds.height ?? 0) + 16)
|
let messageRequestsOffset: CGFloat = ((self?.messageRequestStackView.bounds.height ?? 0) + 12)
|
||||||
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))
|
|
||||||
let oldContentInset: UIEdgeInsets = (self?.tableView.contentInset ?? UIEdgeInsets.zero)
|
let oldContentInset: UIEdgeInsets = (self?.tableView.contentInset ?? UIEdgeInsets.zero)
|
||||||
self?.tableView.contentInset = UIEdgeInsets(
|
self?.tableView.contentInset = UIEdgeInsets(
|
||||||
top: 0,
|
top: 0,
|
||||||
|
@ -1103,9 +1087,12 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
|
||||||
profilePictureView.size = Values.verySmallProfilePictureSize
|
profilePictureView.size = Values.verySmallProfilePictureSize
|
||||||
profilePictureView.update(
|
profilePictureView.update(
|
||||||
publicKey: threadData.threadId, // Contact thread uses the contactId
|
publicKey: threadData.threadId, // Contact thread uses the contactId
|
||||||
|
threadVariant: threadData.threadVariant,
|
||||||
|
customImageData: nil,
|
||||||
profile: threadData.profile,
|
profile: threadData.profile,
|
||||||
threadVariant: threadData.threadVariant
|
additionalProfile: nil
|
||||||
)
|
)
|
||||||
|
|
||||||
profilePictureView.set(.width, to: (44 - 16)) // Width of the standard back button
|
profilePictureView.set(.width, to: (44 - 16)) // Width of the standard back button
|
||||||
profilePictureView.set(.height, to: Values.verySmallProfilePictureSize)
|
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)
|
// needed for proper calculations, so force an initial layout if it doesn't have a size)
|
||||||
var hasDoneLayout: Bool = true
|
var hasDoneLayout: Bool = true
|
||||||
|
|
||||||
if messageRequestView.bounds.height <= CGFloat.leastNonzeroMagnitude {
|
if messageRequestStackView.bounds.height <= CGFloat.leastNonzeroMagnitude {
|
||||||
hasDoneLayout = false
|
hasDoneLayout = false
|
||||||
|
|
||||||
UIView.performWithoutAnimation {
|
UIView.performWithoutAnimation {
|
||||||
|
@ -1168,19 +1155,18 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
|
||||||
}
|
}
|
||||||
|
|
||||||
let keyboardTop = (UIScreen.main.bounds.height - keyboardRect.minY)
|
let keyboardTop = (UIScreen.main.bounds.height - keyboardRect.minY)
|
||||||
let messageRequestsOffset: CGFloat = (messageRequestStackView.isHidden ? 0 : messageRequestStackView.bounds.height + 16)
|
let messageRequestsOffset: CGFloat = (messageRequestStackView.isHidden ? 0 : messageRequestStackView.bounds.height + 12)
|
||||||
let pendingMessageRequestsOffset: CGFloat = (pendingMessageRequestExplanationLabel.isHidden ? 0 : (pendingMessageRequestExplanationLabel.bounds.height + (16 * 2)))
|
|
||||||
let oldContentInset: UIEdgeInsets = tableView.contentInset
|
let oldContentInset: UIEdgeInsets = tableView.contentInset
|
||||||
let newContentInset: UIEdgeInsets = UIEdgeInsets(
|
let newContentInset: UIEdgeInsets = UIEdgeInsets(
|
||||||
top: 0,
|
top: 0,
|
||||||
leading: 0,
|
leading: 0,
|
||||||
bottom: (Values.mediumSpacing + keyboardTop + messageRequestsOffset + pendingMessageRequestsOffset),
|
bottom: (Values.mediumSpacing + keyboardTop + messageRequestsOffset),
|
||||||
trailing: 0
|
trailing: 0
|
||||||
)
|
)
|
||||||
let newContentOffsetY: CGFloat = (tableView.contentOffset.y + (newContentInset.bottom - oldContentInset.bottom))
|
let newContentOffsetY: CGFloat = (tableView.contentOffset.y + (newContentInset.bottom - oldContentInset.bottom))
|
||||||
let changes = { [weak self] in
|
let changes = { [weak self] in
|
||||||
self?.scrollButtonBottomConstraint?.constant = -(keyboardTop + 16)
|
self?.scrollButtonBottomConstraint?.constant = -(keyboardTop + 12)
|
||||||
self?.messageRequestsViewBotomConstraint?.constant = -(keyboardTop + 16)
|
self?.messageRequestsViewBotomConstraint?.constant = -(keyboardTop + 12)
|
||||||
self?.tableView.contentInset = newContentInset
|
self?.tableView.contentInset = newContentInset
|
||||||
self?.tableView.contentOffset.y = newContentOffsetY
|
self?.tableView.contentOffset.y = newContentOffsetY
|
||||||
|
|
||||||
|
@ -1226,8 +1212,8 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
|
||||||
delay: 0,
|
delay: 0,
|
||||||
options: options,
|
options: options,
|
||||||
animations: { [weak self] in
|
animations: { [weak self] in
|
||||||
self?.scrollButtonBottomConstraint?.constant = -(keyboardTop + 16)
|
self?.scrollButtonBottomConstraint?.constant = -(keyboardTop + 12)
|
||||||
self?.messageRequestsViewBotomConstraint?.constant = -(keyboardTop + 16)
|
self?.messageRequestsViewBotomConstraint?.constant = -(keyboardTop + 12)
|
||||||
|
|
||||||
let scrollButtonOpacity: CGFloat = (self?.getScrollButtonOpacity() ?? 0)
|
let scrollButtonOpacity: CGFloat = (self?.getScrollButtonOpacity() ?? 0)
|
||||||
self?.scrollButton.alpha = scrollButtonOpacity
|
self?.scrollButton.alpha = scrollButtonOpacity
|
||||||
|
@ -1536,7 +1522,7 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
|
||||||
searchBar.sizeToFit()
|
searchBar.sizeToFit()
|
||||||
searchBar.layoutMargins = UIEdgeInsets.zero
|
searchBar.layoutMargins = UIEdgeInsets.zero
|
||||||
searchBarContainer.set(.height, to: 44)
|
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)
|
searchBarContainer.addSubview(searchBar)
|
||||||
navigationItem.titleView = searchBarContainer
|
navigationItem.titleView = searchBarContainer
|
||||||
|
|
||||||
|
@ -1676,6 +1662,11 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
|
||||||
animated: (self.didFinishInitialLayout && isAnimated)
|
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
|
// 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
|
// so it doesn't look buggy with the push transition
|
||||||
if highlight {
|
if highlight {
|
||||||
|
|
|
@ -199,8 +199,10 @@ private extension MentionSelectionView {
|
||||||
displayNameLabel.text = profile.displayName(for: threadVariant)
|
displayNameLabel.text = profile.displayName(for: threadVariant)
|
||||||
profilePictureView.update(
|
profilePictureView.update(
|
||||||
publicKey: profile.id,
|
publicKey: profile.id,
|
||||||
|
threadVariant: .contact,
|
||||||
|
customImageData: nil,
|
||||||
profile: profile,
|
profile: profile,
|
||||||
threadVariant: threadVariant
|
additionalProfile: nil
|
||||||
)
|
)
|
||||||
moderatorIconImageView.isHidden = !isUserModeratorOrAdmin
|
moderatorIconImageView.isHidden = !isUserModeratorOrAdmin
|
||||||
separator.isHidden = isLast
|
separator.isHidden = isLast
|
||||||
|
|
|
@ -46,7 +46,7 @@ final class DocumentView: UIView {
|
||||||
// Size label
|
// Size label
|
||||||
let sizeLabel = UILabel()
|
let sizeLabel = UILabel()
|
||||||
sizeLabel.font = .systemFont(ofSize: Values.verySmallFontSize)
|
sizeLabel.font = .systemFont(ofSize: Values.verySmallFontSize)
|
||||||
sizeLabel.text = OWSFormat.formatFileSize(UInt(attachment.byteCount))
|
sizeLabel.text = Format.fileSize(attachment.byteCount)
|
||||||
sizeLabel.themeTextColor = textColor
|
sizeLabel.themeTextColor = textColor
|
||||||
sizeLabel.lineBreakMode = .byTruncatingTail
|
sizeLabel.lineBreakMode = .byTruncatingTail
|
||||||
|
|
||||||
|
|
|
@ -111,11 +111,10 @@ public class MediaAlbumView: UIStackView {
|
||||||
tintView.autoPinEdgesToSuperviewEdges()
|
tintView.autoPinEdgesToSuperviewEdges()
|
||||||
|
|
||||||
let moreCount = max(1, items.count - MediaAlbumView.kMaxItems)
|
let moreCount = max(1, items.count - MediaAlbumView.kMaxItems)
|
||||||
let moreCountText = OWSFormat.formatInt(Int32(moreCount))
|
|
||||||
let moreText = String(
|
let moreText = String(
|
||||||
// Format for the 'more items' indicator for media galleries. Embeds {{the number of additional items}}.
|
// Format for the 'more items' indicator for media galleries. Embeds {{the number of additional items}}.
|
||||||
format: "MEDIA_GALLERY_MORE_ITEMS_FORMAT".localized(),
|
format: "MEDIA_GALLERY_MORE_ITEMS_FORMAT".localized(),
|
||||||
moreCountText
|
"\(moreCount)"
|
||||||
)
|
)
|
||||||
let moreLabel: UILabel = UILabel()
|
let moreLabel: UILabel = UILabel()
|
||||||
moreLabel.font = .systemFont(ofSize: 24)
|
moreLabel.font = .systemFont(ofSize: 24)
|
||||||
|
|
|
@ -289,8 +289,10 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
||||||
profilePictureView.isHidden = (!cellViewModel.shouldShowProfile || cellViewModel.profile == nil)
|
profilePictureView.isHidden = (!cellViewModel.shouldShowProfile || cellViewModel.profile == nil)
|
||||||
profilePictureView.update(
|
profilePictureView.update(
|
||||||
publicKey: cellViewModel.authorId,
|
publicKey: cellViewModel.authorId,
|
||||||
|
threadVariant: cellViewModel.threadVariant,
|
||||||
|
customImageData: nil,
|
||||||
profile: cellViewModel.profile,
|
profile: cellViewModel.profile,
|
||||||
threadVariant: cellViewModel.threadVariant
|
additionalProfile: nil
|
||||||
)
|
)
|
||||||
moderatorIconImageView.isHidden = (!cellViewModel.isSenderOpenGroupModerator || !cellViewModel.shouldShowProfile)
|
moderatorIconImageView.isHidden = (!cellViewModel.isSenderOpenGroupModerator || !cellViewModel.shouldShowProfile)
|
||||||
|
|
||||||
|
|
|
@ -85,10 +85,7 @@ class ThreadDisappearingMessagesViewModel: SessionTableViewModel<ThreadDisappear
|
||||||
|
|
||||||
override var title: String { "DISAPPEARING_MESSAGES".localized() }
|
override var title: String { "DISAPPEARING_MESSAGES".localized() }
|
||||||
|
|
||||||
private var _settingsData: [SectionModel] = []
|
public override var observableTableData: ObservableData { _observableTableData }
|
||||||
public override var settingsData: [SectionModel] { _settingsData }
|
|
||||||
|
|
||||||
public override var observableSettingsData: ObservableData { _observableSettingsData }
|
|
||||||
|
|
||||||
/// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise
|
/// 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
|
/// 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
|
/// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own
|
||||||
/// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`)
|
/// 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
|
/// 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
|
.trackingConstantRegion { [weak self, config] db -> [SectionModel] in
|
||||||
return [
|
return [
|
||||||
SectionModel(
|
SectionModel(
|
||||||
|
@ -131,12 +128,9 @@ class ThreadDisappearingMessagesViewModel: SessionTableViewModel<ThreadDisappear
|
||||||
}
|
}
|
||||||
.removeDuplicates()
|
.removeDuplicates()
|
||||||
.publisher(in: dependencies.storage, scheduling: dependencies.scheduler)
|
.publisher(in: dependencies.storage, scheduling: dependencies.scheduler)
|
||||||
|
.mapToSessionTableViewData(for: self)
|
||||||
|
|
||||||
// MARK: - Functions
|
// MARK: - Functions
|
||||||
|
|
||||||
public override func updateSettings(_ updatedSettings: [SectionModel]) {
|
|
||||||
self._settingsData = updatedSettings
|
|
||||||
}
|
|
||||||
|
|
||||||
private func saveChanges() {
|
private func saveChanges() {
|
||||||
let threadId: String = self.threadId
|
let threadId: String = self.threadId
|
||||||
|
|
|
@ -29,7 +29,10 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum Setting: Differentiable {
|
public enum Setting: Differentiable {
|
||||||
case threadInfo
|
case avatar
|
||||||
|
case nickname
|
||||||
|
case sessionId
|
||||||
|
|
||||||
case copyThreadId
|
case copyThreadId
|
||||||
case allMedia
|
case allMedia
|
||||||
case searchConversation
|
case searchConversation
|
||||||
|
@ -170,10 +173,7 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var _settingsData: [SectionModel] = []
|
public override var observableTableData: ObservableData { _observableTableData }
|
||||||
public override var settingsData: [SectionModel] { _settingsData }
|
|
||||||
|
|
||||||
public override var observableSettingsData: ObservableData { _observableSettingsData }
|
|
||||||
|
|
||||||
/// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise
|
/// 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
|
/// 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
|
/// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own
|
||||||
/// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`)
|
/// 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
|
/// 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
|
.trackingConstantRegion { [weak self, dependencies, threadId = self.threadId, threadVariant = self.threadVariant] db -> [SectionModel] in
|
||||||
let userPublicKey: String = getUserHexEncodedPublicKey(db, dependencies: dependencies)
|
let userPublicKey: String = getUserHexEncodedPublicKey(db, dependencies: dependencies)
|
||||||
let maybeThreadViewModel: SessionThreadViewModel? = try SessionThreadViewModel
|
let maybeThreadViewModel: SessionThreadViewModel? = try SessionThreadViewModel
|
||||||
|
@ -207,25 +207,88 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
||||||
threadVariant == .closedGroup &&
|
threadVariant == .closedGroup &&
|
||||||
threadViewModel.currentUserIsClosedGroupMember == true
|
threadViewModel.currentUserIsClosedGroupMember == true
|
||||||
)
|
)
|
||||||
|
let editIcon: UIImage? = UIImage(named: "icon_edit")
|
||||||
|
|
||||||
return [
|
return [
|
||||||
SectionModel(
|
SectionModel(
|
||||||
model: .conversationInfo,
|
model: .conversationInfo,
|
||||||
elements: [
|
elements: [
|
||||||
SessionCell.Info(
|
SessionCell.Info(
|
||||||
id: .threadInfo,
|
id: .avatar,
|
||||||
leftAccessory: .threadInfo(
|
accessory: .profile(
|
||||||
threadViewModel: threadViewModel,
|
id: threadViewModel.id,
|
||||||
avatarTapped: { [weak self] in
|
size: .extraLarge,
|
||||||
self?.updateProfilePicture(threadViewModel: threadViewModel)
|
threadVariant: threadVariant,
|
||||||
},
|
customImageData: threadViewModel.openGroupProfilePictureData,
|
||||||
titleTapped: { [weak self] in self?.setIsEditing(true) },
|
profile: threadViewModel.profile,
|
||||||
titleChanged: { [weak self] text in self?.editedDisplayName = text }
|
additionalProfile: threadViewModel.additionalProfile,
|
||||||
|
cornerIcon: nil,
|
||||||
|
accessibility: nil
|
||||||
),
|
),
|
||||||
title: threadViewModel.displayName,
|
styling: SessionCell.StyleInfo(
|
||||||
shouldHaveBackground: false
|
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(
|
SectionModel(
|
||||||
model: .content,
|
model: .content,
|
||||||
|
@ -241,27 +304,29 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
||||||
"COPY_GROUP_URL".localized() :
|
"COPY_GROUP_URL".localized() :
|
||||||
"vc_conversation_settings_copy_session_id_button_title".localized()
|
"vc_conversation_settings_copy_session_id_button_title".localized()
|
||||||
),
|
),
|
||||||
accessibilityIdentifier: "\(ThreadSettingsViewModel.self).copy_thread_id",
|
accessibility: SessionCell.Accessibility(
|
||||||
accessibilityLabel: "Copy Session ID",
|
identifier: "\(ThreadSettingsViewModel.self).copy_thread_id",
|
||||||
|
label: "Copy Session ID"
|
||||||
|
),
|
||||||
onTap: {
|
onTap: {
|
||||||
switch threadVariant {
|
switch threadVariant {
|
||||||
case .contact, .closedGroup:
|
case .contact, .closedGroup:
|
||||||
UIPasteboard.general.string = threadId
|
UIPasteboard.general.string = threadId
|
||||||
|
|
||||||
case .openGroup:
|
case .openGroup:
|
||||||
guard
|
guard
|
||||||
let server: String = threadViewModel.openGroupServer,
|
let server: String = threadViewModel.openGroupServer,
|
||||||
let roomToken: String = threadViewModel.openGroupRoomToken,
|
let roomToken: String = threadViewModel.openGroupRoomToken,
|
||||||
let publicKey: String = threadViewModel.openGroupPublicKey
|
let publicKey: String = threadViewModel.openGroupPublicKey
|
||||||
else { return }
|
else { return }
|
||||||
|
|
||||||
UIPasteboard.general.string = OpenGroup.urlFor(
|
UIPasteboard.general.string = OpenGroup.urlFor(
|
||||||
server: server,
|
server: server,
|
||||||
roomToken: roomToken,
|
roomToken: roomToken,
|
||||||
publicKey: publicKey
|
publicKey: publicKey
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
self?.showToast(
|
self?.showToast(
|
||||||
text: "copied".localized(),
|
text: "copied".localized(),
|
||||||
backgroundColor: .backgroundSecondary
|
backgroundColor: .backgroundSecondary
|
||||||
|
@ -269,7 +334,7 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
|
||||||
SessionCell.Info(
|
SessionCell.Info(
|
||||||
id: .allMedia,
|
id: .allMedia,
|
||||||
leftAccessory: .icon(
|
leftAccessory: .icon(
|
||||||
|
@ -277,8 +342,10 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
||||||
.withRenderingMode(.alwaysTemplate)
|
.withRenderingMode(.alwaysTemplate)
|
||||||
),
|
),
|
||||||
title: MediaStrings.allMedia,
|
title: MediaStrings.allMedia,
|
||||||
accessibilityIdentifier: "\(ThreadSettingsViewModel.self).all_media",
|
accessibility: SessionCell.Accessibility(
|
||||||
accessibilityLabel: "All media",
|
identifier: "\(ThreadSettingsViewModel.self).all_media",
|
||||||
|
label: "All media"
|
||||||
|
),
|
||||||
onTap: { [weak self] in
|
onTap: { [weak self] in
|
||||||
self?.transitionToScreen(
|
self?.transitionToScreen(
|
||||||
MediaGalleryViewModel.createAllMediaViewController(
|
MediaGalleryViewModel.createAllMediaViewController(
|
||||||
|
@ -289,7 +356,7 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
|
|
||||||
SessionCell.Info(
|
SessionCell.Info(
|
||||||
id: .searchConversation,
|
id: .searchConversation,
|
||||||
leftAccessory: .icon(
|
leftAccessory: .icon(
|
||||||
|
@ -297,13 +364,15 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
||||||
.withRenderingMode(.alwaysTemplate)
|
.withRenderingMode(.alwaysTemplate)
|
||||||
),
|
),
|
||||||
title: "CONVERSATION_SETTINGS_SEARCH".localized(),
|
title: "CONVERSATION_SETTINGS_SEARCH".localized(),
|
||||||
accessibilityIdentifier: "\(ThreadSettingsViewModel.self).search",
|
accessibility: SessionCell.Accessibility(
|
||||||
accessibilityLabel: "Search",
|
identifier: "\(ThreadSettingsViewModel.self).search",
|
||||||
|
label: "Search"
|
||||||
|
),
|
||||||
onTap: { [weak self] in
|
onTap: { [weak self] in
|
||||||
self?.didTriggerSearch()
|
self?.didTriggerSearch()
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
|
|
||||||
(threadVariant != .openGroup ? nil :
|
(threadVariant != .openGroup ? nil :
|
||||||
SessionCell.Info(
|
SessionCell.Info(
|
||||||
id: .addToOpenGroup,
|
id: .addToOpenGroup,
|
||||||
|
@ -312,7 +381,9 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
||||||
.withRenderingMode(.alwaysTemplate)
|
.withRenderingMode(.alwaysTemplate)
|
||||||
),
|
),
|
||||||
title: "vc_conversation_settings_invite_button_title".localized(),
|
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
|
onTap: { [weak self] in
|
||||||
self?.transitionToScreen(
|
self?.transitionToScreen(
|
||||||
UserSelectionVC(
|
UserSelectionVC(
|
||||||
|
@ -328,7 +399,7 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
|
||||||
(threadVariant == .openGroup || threadViewModel.threadIsBlocked == true ? nil :
|
(threadVariant == .openGroup || threadViewModel.threadIsBlocked == true ? nil :
|
||||||
SessionCell.Info(
|
SessionCell.Info(
|
||||||
id: .disappearingMessages,
|
id: .disappearingMessages,
|
||||||
|
@ -338,7 +409,10 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
||||||
"ic_timer" :
|
"ic_timer" :
|
||||||
"ic_timer_disabled"
|
"ic_timer_disabled"
|
||||||
)
|
)
|
||||||
)?.withRenderingMode(.alwaysTemplate)
|
)?.withRenderingMode(.alwaysTemplate),
|
||||||
|
accessibility: SessionCell.Accessibility(
|
||||||
|
label: "Timer icon"
|
||||||
|
)
|
||||||
),
|
),
|
||||||
title: "DISAPPEARING_MESSAGES".localized(),
|
title: "DISAPPEARING_MESSAGES".localized(),
|
||||||
subtitle: (disappearingMessagesConfig.isEnabled ?
|
subtitle: (disappearingMessagesConfig.isEnabled ?
|
||||||
|
@ -348,9 +422,10 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
||||||
) :
|
) :
|
||||||
"DISAPPEARING_MESSAGES_SUBTITLE_OFF".localized()
|
"DISAPPEARING_MESSAGES_SUBTITLE_OFF".localized()
|
||||||
),
|
),
|
||||||
accessibilityIdentifier: "\(ThreadSettingsViewModel.self).disappearing_messages",
|
accessibility: SessionCell.Accessibility(
|
||||||
accessibilityLabel: "Disappearing messages",
|
identifier: "\(ThreadSettingsViewModel.self).disappearing_messages",
|
||||||
leftAccessoryAccessibilityLabel: "Timer icon",
|
label: "Disappearing messages"
|
||||||
|
),
|
||||||
onTap: { [weak self] in
|
onTap: { [weak self] in
|
||||||
self?.transitionToScreen(
|
self?.transitionToScreen(
|
||||||
SessionTableViewController(
|
SessionTableViewController(
|
||||||
|
@ -363,7 +438,7 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
|
||||||
(!currentUserIsClosedGroupMember ? nil :
|
(!currentUserIsClosedGroupMember ? nil :
|
||||||
SessionCell.Info(
|
SessionCell.Info(
|
||||||
id: .editGroup,
|
id: .editGroup,
|
||||||
|
@ -372,8 +447,10 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
||||||
.withRenderingMode(.alwaysTemplate)
|
.withRenderingMode(.alwaysTemplate)
|
||||||
),
|
),
|
||||||
title: "EDIT_GROUP_ACTION".localized(),
|
title: "EDIT_GROUP_ACTION".localized(),
|
||||||
accessibilityIdentifier: "Edit group",
|
accessibility: SessionCell.Accessibility(
|
||||||
accessibilityLabel: "Edit group",
|
identifier: "Edit group",
|
||||||
|
label: "Edit group"
|
||||||
|
),
|
||||||
onTap: { [weak self] in
|
onTap: { [weak self] in
|
||||||
self?.transitionToScreen(EditClosedGroupVC(threadId: threadId))
|
self?.transitionToScreen(EditClosedGroupVC(threadId: threadId))
|
||||||
}
|
}
|
||||||
|
@ -388,8 +465,10 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
||||||
.withRenderingMode(.alwaysTemplate)
|
.withRenderingMode(.alwaysTemplate)
|
||||||
),
|
),
|
||||||
title: "LEAVE_GROUP_ACTION".localized(),
|
title: "LEAVE_GROUP_ACTION".localized(),
|
||||||
accessibilityIdentifier: "Leave group",
|
accessibility: SessionCell.Accessibility(
|
||||||
accessibilityLabel: "Leave group",
|
identifier: "Leave group",
|
||||||
|
label: "Leave group"
|
||||||
|
),
|
||||||
confirmationInfo: ConfirmationModal.Info(
|
confirmationInfo: ConfirmationModal.Info(
|
||||||
title: "CONFIRM_LEAVE_GROUP_TITLE".localized(),
|
title: "CONFIRM_LEAVE_GROUP_TITLE".localized(),
|
||||||
explanation: (currentUserIsClosedGroupMember ?
|
explanation: (currentUserIsClosedGroupMember ?
|
||||||
|
@ -401,9 +480,11 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
||||||
cancelStyle: .alert_text
|
cancelStyle: .alert_text
|
||||||
),
|
),
|
||||||
onTap: { [weak self] in
|
onTap: { [weak self] in
|
||||||
dependencies.storage.writeAsync { db in
|
dependencies.storage
|
||||||
try MessageSender.leave(db, groupPublicKey: threadId)
|
.writePublisherFlatMap { db in
|
||||||
}
|
MessageSender.leave(db, groupPublicKey: threadId)
|
||||||
|
}
|
||||||
|
.sinkUntilComplete()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
@ -445,8 +526,10 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
||||||
threadViewModel.threadVariant != .closedGroup ||
|
threadViewModel.threadVariant != .closedGroup ||
|
||||||
currentUserIsClosedGroupMember
|
currentUserIsClosedGroupMember
|
||||||
),
|
),
|
||||||
accessibilityIdentifier: "Mentions only notification setting",
|
accessibility: SessionCell.Accessibility(
|
||||||
accessibilityLabel: "Mentions only",
|
identifier: "Mentions only notification setting",
|
||||||
|
label: "Mentions only"
|
||||||
|
),
|
||||||
onTap: {
|
onTap: {
|
||||||
let newValue: Bool = !(threadViewModel.threadOnlyNotifyForMentions == true)
|
let newValue: Bool = !(threadViewModel.threadOnlyNotifyForMentions == true)
|
||||||
|
|
||||||
|
@ -478,8 +561,10 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
||||||
threadViewModel.threadVariant != .closedGroup ||
|
threadViewModel.threadVariant != .closedGroup ||
|
||||||
currentUserIsClosedGroupMember
|
currentUserIsClosedGroupMember
|
||||||
),
|
),
|
||||||
accessibilityIdentifier: "\(ThreadSettingsViewModel.self).mute",
|
accessibility: SessionCell.Accessibility(
|
||||||
accessibilityLabel: "Mute notifications",
|
identifier: "\(ThreadSettingsViewModel.self).mute",
|
||||||
|
label: "Mute notifications"
|
||||||
|
),
|
||||||
onTap: {
|
onTap: {
|
||||||
dependencies.storage.writeAsync { db in
|
dependencies.storage.writeAsync { db in
|
||||||
let currentValue: TimeInterval? = try SessionThread
|
let currentValue: TimeInterval? = try SessionThread
|
||||||
|
@ -515,8 +600,10 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
||||||
rightAccessory: .toggle(
|
rightAccessory: .toggle(
|
||||||
.boolValue(threadViewModel.threadIsBlocked == true)
|
.boolValue(threadViewModel.threadIsBlocked == true)
|
||||||
),
|
),
|
||||||
accessibilityIdentifier: "\(ThreadSettingsViewModel.self).block",
|
accessibility: SessionCell.Accessibility(
|
||||||
accessibilityLabel: "Block",
|
identifier: "\(ThreadSettingsViewModel.self).block",
|
||||||
|
label: "Block"
|
||||||
|
),
|
||||||
confirmationInfo: ConfirmationModal.Info(
|
confirmationInfo: ConfirmationModal.Info(
|
||||||
title: {
|
title: {
|
||||||
guard threadViewModel.threadIsBlocked == true else {
|
guard threadViewModel.threadIsBlocked == true else {
|
||||||
|
@ -561,14 +648,11 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
||||||
}
|
}
|
||||||
.removeDuplicates()
|
.removeDuplicates()
|
||||||
.publisher(in: dependencies.storage, scheduling: dependencies.scheduler)
|
.publisher(in: dependencies.storage, scheduling: dependencies.scheduler)
|
||||||
|
.mapToSessionTableViewData(for: self)
|
||||||
|
|
||||||
// MARK: - Functions
|
// MARK: - Functions
|
||||||
|
|
||||||
public override func updateSettings(_ updatedSettings: [SectionModel]) {
|
|
||||||
self._settingsData = updatedSettings
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateProfilePicture(threadViewModel: SessionThreadViewModel) {
|
private func viewProfilePicture(threadViewModel: SessionThreadViewModel) {
|
||||||
guard
|
guard
|
||||||
threadViewModel.threadVariant == .contact,
|
threadViewModel.threadVariant == .contact,
|
||||||
let profile: Profile = threadViewModel.profile,
|
let profile: Profile = threadViewModel.profile,
|
||||||
|
|
|
@ -431,7 +431,8 @@ extension ReactionListSheet: UITableViewDelegate, UITableViewDataSource {
|
||||||
cell.update(
|
cell.update(
|
||||||
with: SessionCell.Info(
|
with: SessionCell.Info(
|
||||||
id: cellViewModel,
|
id: cellViewModel,
|
||||||
leftAccessory: .profile(authorId, cellViewModel.profile),
|
position: Position.with(indexPath.row, count: self.selectedReactionUserList.count),
|
||||||
|
leftAccessory: .profile(id: authorId, profile: cellViewModel.profile),
|
||||||
title: (
|
title: (
|
||||||
cellViewModel.profile?.displayName() ??
|
cellViewModel.profile?.displayName() ??
|
||||||
Profile.truncated(
|
Profile.truncated(
|
||||||
|
@ -446,10 +447,9 @@ extension ReactionListSheet: UITableViewDelegate, UITableViewDataSource {
|
||||||
size: .fit
|
size: .fit
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
styling: SessionCell.StyleInfo(backgroundStyle: .edgeToEdge),
|
||||||
isEnabled: (authorId == self.messageViewModel.currentUserPublicKey)
|
isEnabled: (authorId == self.messageViewModel.currentUserPublicKey)
|
||||||
),
|
)
|
||||||
style: .edgeToEdge,
|
|
||||||
position: Position.with(indexPath.row, count: self.selectedReactionUserList.count)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return cell
|
return cell
|
||||||
|
|
|
@ -472,8 +472,10 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
|
||||||
profilePictureView.size = profilePictureSize
|
profilePictureView.size = profilePictureSize
|
||||||
profilePictureView.update(
|
profilePictureView.update(
|
||||||
publicKey: getUserHexEncodedPublicKey(),
|
publicKey: getUserHexEncodedPublicKey(),
|
||||||
|
threadVariant: .contact,
|
||||||
|
customImageData: nil,
|
||||||
profile: Profile.fetchOrCreateCurrentUser(),
|
profile: Profile.fetchOrCreateCurrentUser(),
|
||||||
threadVariant: .contact
|
additionalProfile: nil
|
||||||
)
|
)
|
||||||
profilePictureView.set(.width, to: profilePictureSize)
|
profilePictureView.set(.width, to: profilePictureSize)
|
||||||
profilePictureView.set(.height, to: profilePictureSize)
|
profilePictureView.set(.height, to: profilePictureSize)
|
||||||
|
|
|
@ -142,13 +142,13 @@ final class NewConversationVC: BaseVC, ThemedNavigation, UITableViewDelegate, UI
|
||||||
cell.update(
|
cell.update(
|
||||||
with: SessionCell.Info(
|
with: SessionCell.Info(
|
||||||
id: profile,
|
id: profile,
|
||||||
leftAccessory: .profile(profile.id, profile),
|
position: Position.with(
|
||||||
title: profile.displayName()
|
indexPath.row,
|
||||||
),
|
count: newConversationViewModel.sectionData[indexPath.section].contacts.count
|
||||||
style: .edgeToEdge,
|
),
|
||||||
position: Position.with(
|
leftAccessory: .profile(id: profile.id, profile: profile),
|
||||||
indexPath.row,
|
title: profile.displayName(),
|
||||||
count: newConversationViewModel.sectionData[indexPath.section].contacts.count
|
styling: SessionCell.StyleInfo(backgroundStyle: .edgeToEdge)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -497,7 +497,7 @@ class DocumentCell: UITableViewCell {
|
||||||
func update(with item: MediaGalleryViewModel.Item) {
|
func update(with item: MediaGalleryViewModel.Item) {
|
||||||
let attachment = item.attachment
|
let attachment = item.attachment
|
||||||
titleLabel.text = (attachment.sourceFilename ?? "File")
|
titleLabel.text = (attachment.sourceFilename ?? "File")
|
||||||
detailLabel.text = "\(OWSFormat.formatFileSize(UInt(attachment.byteCount)))"
|
detailLabel.text = "\(Format.fileSize(attachment.byteCount)))"
|
||||||
timeLabel.text = Date(
|
timeLabel.text = Date(
|
||||||
timeIntervalSince1970: TimeInterval(item.interactionTimestampMs / 1000)
|
timeIntervalSince1970: TimeInterval(item.interactionTimestampMs / 1000)
|
||||||
).formattedForDisplay
|
).formattedForDisplay
|
||||||
|
|
|
@ -34,12 +34,9 @@ class PhotoCollectionPickerViewModel: SessionTableViewModel<NoNav, PhotoCollecti
|
||||||
// MARK: - Content
|
// MARK: - Content
|
||||||
|
|
||||||
override var title: String { "NOTIFICATIONS_STYLE_SOUND_TITLE".localized() }
|
override var title: String { "NOTIFICATIONS_STYLE_SOUND_TITLE".localized() }
|
||||||
|
override var observableTableData: ObservableData { _observableTableData }
|
||||||
|
|
||||||
private var _settingsData: [SectionModel] = []
|
private lazy var _observableTableData: ObservableData = {
|
||||||
public override var settingsData: [SectionModel] { _settingsData }
|
|
||||||
public override var observableSettingsData: ObservableData { _observableSettingsData }
|
|
||||||
|
|
||||||
private lazy var _observableSettingsData: ObservableData = {
|
|
||||||
self.photoCollections
|
self.photoCollections
|
||||||
.map { collections in
|
.map { collections in
|
||||||
[
|
[
|
||||||
|
@ -49,15 +46,15 @@ class PhotoCollectionPickerViewModel: SessionTableViewModel<NoNav, PhotoCollecti
|
||||||
let contents: PhotoCollectionContents = collection.contents()
|
let contents: PhotoCollectionContents = collection.contents()
|
||||||
let photoMediaSize: PhotoMediaSize = PhotoMediaSize(
|
let photoMediaSize: PhotoMediaSize = PhotoMediaSize(
|
||||||
thumbnailSize: CGSize(
|
thumbnailSize: CGSize(
|
||||||
width: IconSize.veryLarge.size,
|
width: IconSize.extraLarge.size,
|
||||||
height: IconSize.veryLarge.size
|
height: IconSize.extraLarge.size
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
let lastAssetItem: PhotoPickerAssetItem? = contents.lastAssetItem(photoMediaSize: photoMediaSize)
|
let lastAssetItem: PhotoPickerAssetItem? = contents.lastAssetItem(photoMediaSize: photoMediaSize)
|
||||||
|
|
||||||
return SessionCell.Info(
|
return SessionCell.Info(
|
||||||
id: Item(id: collection.id),
|
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
|
// Note: We need to capture 'lastAssetItem' otherwise it'll be released and we won't
|
||||||
// be able to load the thumbnail
|
// be able to load the thumbnail
|
||||||
lastAssetItem?.asyncThumbnail { [weak imageView] image in
|
lastAssetItem?.asyncThumbnail { [weak imageView] image in
|
||||||
|
@ -76,14 +73,9 @@ class PhotoCollectionPickerViewModel: SessionTableViewModel<NoNav, PhotoCollecti
|
||||||
}
|
}
|
||||||
.removeDuplicates()
|
.removeDuplicates()
|
||||||
.eraseToAnyPublisher()
|
.eraseToAnyPublisher()
|
||||||
|
.mapToSessionTableViewData(for: self)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// MARK: - Functions
|
|
||||||
|
|
||||||
public override func updateSettings(_ updatedSettings: [SectionModel]) {
|
|
||||||
self._settingsData = updatedSettings
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: PhotoLibraryDelegate
|
// MARK: PhotoLibraryDelegate
|
||||||
|
|
||||||
func photoLibraryDidChange(_ photoLibrary: PhotoLibrary) {
|
func photoLibraryDidChange(_ photoLibrary: PhotoLibrary) {
|
||||||
|
|
|
@ -20,7 +20,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
||||||
private var loadingViewController: LoadingViewController?
|
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
|
/// 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
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
|
@ -564,7 +564,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
||||||
public func startPollersIfNeeded(shouldStartGroupPollers: Bool = true) {
|
public func startPollersIfNeeded(shouldStartGroupPollers: Bool = true) {
|
||||||
guard Identity.userExists() else { return }
|
guard Identity.userExists() else { return }
|
||||||
|
|
||||||
poller.startIfNeeded()
|
poller.start()
|
||||||
|
|
||||||
guard shouldStartGroupPollers else { return }
|
guard shouldStartGroupPollers else { return }
|
||||||
|
|
||||||
|
@ -574,7 +574,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
||||||
|
|
||||||
public func stopPollers(shouldStopUserPoller: Bool = true) {
|
public func stopPollers(shouldStopUserPoller: Bool = true) {
|
||||||
if shouldStopUserPoller {
|
if shouldStopUserPoller {
|
||||||
poller.stop()
|
poller.stopAllPollers()
|
||||||
}
|
}
|
||||||
|
|
||||||
ClosedGroupPoller.shared.stopAllPollers()
|
ClosedGroupPoller.shared.stopAllPollers()
|
||||||
|
|
|
@ -17,7 +17,6 @@
|
||||||
#import <Reachability/Reachability.h>
|
#import <Reachability/Reachability.h>
|
||||||
#import <SignalCoreKit/Cryptography.h>
|
#import <SignalCoreKit/Cryptography.h>
|
||||||
#import <SessionMessagingKit/OWSAudioPlayer.h>
|
#import <SessionMessagingKit/OWSAudioPlayer.h>
|
||||||
#import <SignalUtilitiesKit/OWSFormat.h>
|
|
||||||
#import <SignalUtilitiesKit/OWSViewController.h>
|
#import <SignalUtilitiesKit/OWSViewController.h>
|
||||||
#import <SignalUtilitiesKit/UIFont+OWS.h>
|
#import <SignalUtilitiesKit/UIFont+OWS.h>
|
||||||
#import <SessionUtilitiesKit/UIView+OWS.h>
|
#import <SessionUtilitiesKit/UIView+OWS.h>
|
||||||
|
|
|
@ -596,3 +596,5 @@
|
||||||
"MESSAGE_STATE_READ" = "Read";
|
"MESSAGE_STATE_READ" = "Read";
|
||||||
"MESSAGE_STATE_SENT" = "Sent";
|
"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";
|
"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_READ" = "Read";
|
||||||
"MESSAGE_STATE_SENT" = "Sent";
|
"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";
|
"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_READ" = "Read";
|
||||||
"MESSAGE_STATE_SENT" = "Sent";
|
"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";
|
"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_READ" = "خوانده شد";
|
||||||
"MESSAGE_STATE_SENT" = "ارسال شد";
|
"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";
|
"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_READ" = "Read";
|
||||||
"MESSAGE_STATE_SENT" = "Sent";
|
"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";
|
"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_READ" = "Read";
|
||||||
"MESSAGE_STATE_SENT" = "Sent";
|
"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";
|
"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_READ" = "Read";
|
||||||
"MESSAGE_STATE_SENT" = "Sent";
|
"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";
|
"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_READ" = "Read";
|
||||||
"MESSAGE_STATE_SENT" = "Sent";
|
"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";
|
"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_READ" = "Read";
|
||||||
"MESSAGE_STATE_SENT" = "Sent";
|
"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";
|
"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_READ" = "Read";
|
||||||
"MESSAGE_STATE_SENT" = "Sent";
|
"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";
|
"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_READ" = "Read";
|
||||||
"MESSAGE_STATE_SENT" = "Sent";
|
"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";
|
"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_READ" = "Read";
|
||||||
"MESSAGE_STATE_SENT" = "Sent";
|
"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";
|
"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_READ" = "Read";
|
||||||
"MESSAGE_STATE_SENT" = "Sent";
|
"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";
|
"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_READ" = "Read";
|
||||||
"MESSAGE_STATE_SENT" = "Sent";
|
"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";
|
"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_READ" = "Read";
|
||||||
"MESSAGE_STATE_SENT" = "Sent";
|
"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";
|
"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_READ" = "Read";
|
||||||
"MESSAGE_STATE_SENT" = "Sent";
|
"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";
|
"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_READ" = "Read";
|
||||||
"MESSAGE_STATE_SENT" = "Sent";
|
"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";
|
"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_READ" = "Read";
|
||||||
"MESSAGE_STATE_SENT" = "Sent";
|
"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";
|
"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_READ" = "Read";
|
||||||
"MESSAGE_STATE_SENT" = "Sent";
|
"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";
|
"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_READ" = "Read";
|
||||||
"MESSAGE_STATE_SENT" = "Sent";
|
"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";
|
"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_READ" = "Read";
|
||||||
"MESSAGE_STATE_SENT" = "Sent";
|
"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";
|
"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_READ" = "Read";
|
||||||
"MESSAGE_STATE_SENT" = "Sent";
|
"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";
|
"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,
|
variant: .standardOutgoing,
|
||||||
body: replyText,
|
body: replyText,
|
||||||
timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000)),
|
timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000)),
|
||||||
hasMention: Interaction.isUserMentioned(
|
hasMention: Interaction.isUserMentioned(db, threadId: threadId, body: replyText),
|
||||||
db,
|
|
||||||
threadId: threadId,
|
|
||||||
threadVariant: thread.variant,
|
|
||||||
body: replyText
|
|
||||||
),
|
|
||||||
expiresInSeconds: try? DisappearingMessagesConfiguration
|
expiresInSeconds: try? DisappearingMessagesConfiguration
|
||||||
.select(.durationSeconds)
|
.select(.durationSeconds)
|
||||||
.filter(id: threadId)
|
.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.
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import Combine
|
||||||
import GRDB
|
import GRDB
|
||||||
import DifferenceKit
|
import DifferenceKit
|
||||||
|
import SessionUIKit
|
||||||
import SignalUtilitiesKit
|
import SignalUtilitiesKit
|
||||||
|
|
||||||
public class BlockedContactsViewModel {
|
class BlockedContactsViewModel: SessionTableViewModel<NoNav, BlockedContactsViewModel.Section, Profile> {
|
||||||
public typealias SectionModel = ArraySection<Section, SessionCell.Info<Profile>>
|
|
||||||
|
|
||||||
// MARK: - Section
|
// MARK: - Section
|
||||||
|
|
||||||
public enum Section: Differentiable {
|
public enum Section: SessionTableSection {
|
||||||
case contacts
|
case contacts
|
||||||
case loadMore
|
case loadMore
|
||||||
|
|
||||||
|
var style: SessionTableSectionStyle {
|
||||||
|
switch self {
|
||||||
|
case .contacts: return .none
|
||||||
|
case .loadMore: return .loadMore
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Variables
|
// MARK: - Variables
|
||||||
|
@ -21,14 +28,16 @@ public class BlockedContactsViewModel {
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
|
|
||||||
init() {
|
override init() {
|
||||||
self.pagedDataObserver = nil
|
_pagedDataObserver = nil
|
||||||
|
|
||||||
|
super.init()
|
||||||
|
|
||||||
// Note: Since this references self we need to finish initializing before setting it, we
|
// 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
|
// 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
|
// doesn't stutter (it should load basically immediately but without this there is a
|
||||||
// distinct stutter)
|
// distinct stutter)
|
||||||
self.pagedDataObserver = PagedDatabaseObserver(
|
_pagedDataObserver = PagedDatabaseObserver(
|
||||||
pagedTable: Profile.self,
|
pagedTable: Profile.self,
|
||||||
pageSize: BlockedContactsViewModel.pageSize,
|
pageSize: BlockedContactsViewModel.pageSize,
|
||||||
idColumn: .id,
|
idColumn: .id,
|
||||||
|
@ -63,12 +72,13 @@ public class BlockedContactsViewModel {
|
||||||
),
|
),
|
||||||
onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in
|
onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in
|
||||||
PagedData.processAndTriggerUpdates(
|
PagedData.processAndTriggerUpdates(
|
||||||
updatedData: self?.process(data: updatedData, for: updatedPageInfo),
|
updatedData: self?.process(data: updatedData, for: updatedPageInfo)
|
||||||
currentDataRetriever: { self?.contactData },
|
.mapToSessionTableViewData(for: self),
|
||||||
onDataChange: self?.onContactChange,
|
currentDataRetriever: { self?.tableData },
|
||||||
onUnobservedDataChange: { updatedData, changeset in
|
onDataChange: { updatedData, changeset in
|
||||||
self?.unobservedContactDataChanges = (updatedData, changeset)
|
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
|
// Run the initial query on a background thread so we don't block the push transition
|
||||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||||
// The `.pageBefore` will query from a `0` offset loading the first page
|
// The `.pageBefore` will query from a `0` offset loading the first page
|
||||||
self?.pagedDataObserver?.load(.pageBefore)
|
self?._pagedDataObserver?.load(.pageBefore)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Contact Data
|
// MARK: - Contact Data
|
||||||
|
|
||||||
public private(set) var selectedContactIds: Set<String> = []
|
override var title: String { "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_TITLE".localized() }
|
||||||
public private(set) var unobservedContactDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>)?
|
override var emptyStateTextPublisher: AnyPublisher<String?, Never> {
|
||||||
public private(set) var contactData: [SectionModel] = []
|
Just("CONVERSATION_SETTINGS_BLOCKED_CONTACTS_EMPTY_STATE".localized())
|
||||||
public private(set) var pagedDataObserver: PagedDatabaseObserver<Profile, DataModel>?
|
.eraseToAnyPublisher()
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func process(data: [DataModel], for pageInfo: PagedData.PageInfo) -> [SectionModel] {
|
private let contactDataSubject: CurrentValueSubject<([SectionModel], StagedChangeset<[SectionModel]>), Never> = CurrentValueSubject(([], StagedChangeset()))
|
||||||
// Update the 'selectedContactIds' to only include selected contacts which are within the
|
private let selectedContactIdsSubject: CurrentValueSubject<Set<String>, Never> = CurrentValueSubject([])
|
||||||
// data (ie. handle profile deletions)
|
private var _pagedDataObserver: PagedDatabaseObserver<Profile, DataModel>?
|
||||||
let profileIds: Set<String> = data.map { $0.id }.asSet()
|
public override var pagedDataObserver: TransactionObserver? { _pagedDataObserver }
|
||||||
selectedContactIds = selectedContactIds.intersection(profileIds)
|
|
||||||
|
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 [
|
return [
|
||||||
[
|
[
|
||||||
SectionModel(
|
SectionModel(
|
||||||
section: .contacts,
|
section: .contacts,
|
||||||
elements: data
|
elements: data
|
||||||
.sorted { lhs, rhs -> Bool in
|
.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(
|
SessionCell.Info(
|
||||||
id: model.profile,
|
id: model.profile,
|
||||||
leftAccessory: .profile(model.profile.id, model.profile),
|
leftAccessory: .profile(id: model.profile.id, profile: model.profile),
|
||||||
title: model.profile.displayName(),
|
title: model.profile.displayName(),
|
||||||
rightAccessory: .radio(
|
rightAccessory: .radio(
|
||||||
isSelected: { [weak self] in
|
isSelected: {
|
||||||
self?.selectedContactIds.contains(model.profile.id) == true
|
self?.selectedContactIdsSubject.value.contains(model.profile.id) == true
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
onTap: { [weak self] in
|
onTap: {
|
||||||
guard self?.selectedContactIds.contains(model.profile.id) == true else {
|
var updatedSelectedIds: Set<String> = (self?.selectedContactIdsSubject.value ?? [])
|
||||||
self?.selectedContactIds.insert(model.profile.id)
|
|
||||||
return
|
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(),
|
confirmTitle: "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK_CONFIRMATION_ACTON".localized(),
|
||||||
confirmStyle: .danger,
|
confirmStyle: .danger,
|
||||||
cancelStyle: .alert_text
|
cancelStyle: .alert_text
|
||||||
) { _ in
|
) { [weak self] _ in
|
||||||
// Unblock the contacts
|
// Unblock the contacts
|
||||||
Storage.shared.write { db in
|
Storage.shared.write { db in
|
||||||
_ = try Contact
|
_ = try Contact
|
||||||
|
@ -220,6 +251,8 @@ public class BlockedContactsViewModel {
|
||||||
// Force a config sync
|
// Force a config sync
|
||||||
try MessageSender.syncConfiguration(db, forceSyncNow: true).sinkUntilComplete()
|
try MessageSender.syncConfiguration(db, forceSyncNow: true).sinkUntilComplete()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self?.selectedContactIdsSubject.send([])
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
self.transitionToScreen(confirmationModal, transitionType: .present)
|
self.transitionToScreen(confirmationModal, transitionType: .present)
|
||||||
|
@ -242,8 +275,8 @@ public class BlockedContactsViewModel {
|
||||||
static func query(
|
static func query(
|
||||||
filterSQL: SQL,
|
filterSQL: SQL,
|
||||||
orderSQL: SQL
|
orderSQL: SQL
|
||||||
) -> (([Int64]) -> AdaptedFetchRequest<SQLRequest<DataModel>>) {
|
) -> (([Int64]) -> any FetchRequest<DataModel>) {
|
||||||
return { rowIds -> AdaptedFetchRequest<SQLRequest<DataModel>> in
|
return { rowIds -> any FetchRequest<DataModel> in
|
||||||
let profile: TypedTableAlias<Profile> = TypedTableAlias()
|
let profile: TypedTableAlias<Profile> = TypedTableAlias()
|
||||||
|
|
||||||
/// **Note:** The `numColumnsBeforeProfile` value **MUST** match the number of fields before
|
/// **Note:** The `numColumnsBeforeProfile` value **MUST** match the number of fields before
|
||||||
|
|
|
@ -26,7 +26,7 @@ class ConversationSettingsViewModel: SessionTableViewModel<NoNav, ConversationSe
|
||||||
var style: SessionTableSectionStyle {
|
var style: SessionTableSectionStyle {
|
||||||
switch self {
|
switch self {
|
||||||
case .blockedContacts: return .padding
|
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() }
|
override var title: String { "CONVERSATION_SETTINGS_TITLE".localized() }
|
||||||
|
|
||||||
private var _settingsData: [SectionModel] = []
|
public override var observableTableData: ObservableData { _observableTableData }
|
||||||
public override var settingsData: [SectionModel] { _settingsData }
|
|
||||||
|
|
||||||
public override var observableSettingsData: ObservableData { _observableSettingsData }
|
|
||||||
|
|
||||||
/// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise
|
/// 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
|
/// 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
|
/// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own
|
||||||
/// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`)
|
/// 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
|
/// 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
|
.trackingConstantRegion { db -> [SectionModel] in
|
||||||
return [
|
return [
|
||||||
SectionModel(
|
SectionModel(
|
||||||
|
@ -92,10 +89,14 @@ class ConversationSettingsViewModel: SessionTableViewModel<NoNav, ConversationSe
|
||||||
SessionCell.Info(
|
SessionCell.Info(
|
||||||
id: .blockedContacts,
|
id: .blockedContacts,
|
||||||
title: "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_TITLE".localized(),
|
title: "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_TITLE".localized(),
|
||||||
tintColor: .danger,
|
styling: SessionCell.StyleInfo(
|
||||||
shouldHaveBackground: false,
|
tintColor: .danger,
|
||||||
|
backgroundStyle: .noBackground
|
||||||
|
),
|
||||||
onTap: { [weak self] in
|
onTap: { [weak self] in
|
||||||
self?.transitionToScreen(BlockedContactsViewController())
|
self?.transitionToScreen(
|
||||||
|
SessionTableViewController(viewModel: BlockedContactsViewModel())
|
||||||
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
@ -104,10 +105,5 @@ class ConversationSettingsViewModel: SessionTableViewModel<NoNav, ConversationSe
|
||||||
}
|
}
|
||||||
.removeDuplicates()
|
.removeDuplicates()
|
||||||
.publisher(in: Storage.shared)
|
.publisher(in: Storage.shared)
|
||||||
|
.mapToSessionTableViewData(for: self)
|
||||||
// MARK: - Functions
|
|
||||||
|
|
||||||
public override func updateSettings(_ updatedSettings: [SectionModel]) {
|
|
||||||
self._settingsData = updatedSettings
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,10 +25,7 @@ class HelpViewModel: SessionTableViewModel<NoNav, HelpViewModel.Section, HelpVie
|
||||||
|
|
||||||
override var title: String { "HELP_TITLE".localized() }
|
override var title: String { "HELP_TITLE".localized() }
|
||||||
|
|
||||||
private var _settingsData: [SectionModel] = []
|
public override var observableTableData: ObservableData { _observableTableData }
|
||||||
public override var settingsData: [SectionModel] { _settingsData }
|
|
||||||
|
|
||||||
public override var observableSettingsData: ObservableData { _observableSettingsData }
|
|
||||||
|
|
||||||
/// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise
|
/// 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
|
/// 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
|
/// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own
|
||||||
/// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`)
|
/// 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
|
/// 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
|
.trackingConstantRegion { db -> [SectionModel] in
|
||||||
return [
|
return [
|
||||||
SectionModel(
|
SectionModel(
|
||||||
|
@ -50,7 +47,7 @@ class HelpViewModel: SessionTableViewModel<NoNav, HelpViewModel.Section, HelpVie
|
||||||
rightAccessory: .highlightingBackgroundLabel(
|
rightAccessory: .highlightingBackgroundLabel(
|
||||||
title: "HELP_REPORT_BUG_ACTION_TITLE".localized()
|
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()
|
.removeDuplicates()
|
||||||
.publisher(in: Storage.shared)
|
.publisher(in: Storage.shared)
|
||||||
|
.mapToSessionTableViewData(for: self)
|
||||||
|
|
||||||
// MARK: - Functions
|
// MARK: - Functions
|
||||||
|
|
||||||
public override func updateSettings(_ updatedSettings: [SectionModel]) {
|
|
||||||
self._settingsData = updatedSettings
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func shareLogs(
|
public static func shareLogs(
|
||||||
viewControllerToDismiss: UIViewController? = nil,
|
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() }
|
override var title: String { "NOTIFICATIONS_STYLE_CONTENT_TITLE".localized() }
|
||||||
|
|
||||||
private var _settingsData: [SectionModel] = []
|
public override var observableTableData: ObservableData { _observableTableData }
|
||||||
public override var settingsData: [SectionModel] { _settingsData }
|
|
||||||
|
|
||||||
public override var observableSettingsData: ObservableData { _observableSettingsData }
|
|
||||||
|
|
||||||
/// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise
|
/// 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
|
/// 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
|
/// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own
|
||||||
/// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`)
|
/// 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
|
/// 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
|
.trackingConstantRegion { [storage] db -> [SectionModel] in
|
||||||
let currentSelection: Preferences.NotificationPreviewType? = db[.preferencesNotificationPreviewType]
|
let currentSelection: Preferences.NotificationPreviewType? = db[.preferencesNotificationPreviewType]
|
||||||
.defaulting(to: .defaultPreviewType)
|
.defaulting(to: .defaultPreviewType)
|
||||||
|
@ -73,10 +70,5 @@ class NotificationContentViewModel: SessionTableViewModel<NoNav, NotificationSet
|
||||||
}
|
}
|
||||||
.removeDuplicates()
|
.removeDuplicates()
|
||||||
.publisher(in: storage, scheduling: scheduler)
|
.publisher(in: storage, scheduling: scheduler)
|
||||||
|
.mapToSessionTableViewData(for: self)
|
||||||
// MARK: - Functions
|
|
||||||
|
|
||||||
public override func updateSettings(_ updatedSettings: [SectionModel]) {
|
|
||||||
self._settingsData = updatedSettings
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,13 +26,14 @@ class NotificationSettingsViewModel: SessionTableViewModel<NoNav, NotificationSe
|
||||||
var style: SessionTableSectionStyle {
|
var style: SessionTableSectionStyle {
|
||||||
switch self {
|
switch self {
|
||||||
case .content: return .padding
|
case .content: return .padding
|
||||||
default: return .title
|
default: return .titleRoundedContent
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum Setting: Differentiable {
|
public enum Setting: Differentiable {
|
||||||
case strategyUseFastMode
|
case strategyUseFastMode
|
||||||
|
case strategyDeviceSettings
|
||||||
case styleSound
|
case styleSound
|
||||||
case styleSoundWhenAppIsOpen
|
case styleSoundWhenAppIsOpen
|
||||||
case content
|
case content
|
||||||
|
@ -42,10 +43,7 @@ class NotificationSettingsViewModel: SessionTableViewModel<NoNav, NotificationSe
|
||||||
|
|
||||||
override var title: String { "NOTIFICATIONS_TITLE".localized() }
|
override var title: String { "NOTIFICATIONS_TITLE".localized() }
|
||||||
|
|
||||||
private var _settingsData: [SectionModel] = []
|
public override var observableTableData: ObservableData { _observableTableData }
|
||||||
public override var settingsData: [SectionModel] { _settingsData }
|
|
||||||
|
|
||||||
public override var observableSettingsData: ObservableData { _observableSettingsData }
|
|
||||||
|
|
||||||
/// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise
|
/// 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
|
/// 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
|
/// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own
|
||||||
/// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`)
|
/// 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
|
/// 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
|
.trackingConstantRegion { db -> [SectionModel] in
|
||||||
let notificationSound: Preferences.Sound = db[.defaultNotificationSound]
|
let notificationSound: Preferences.Sound = db[.defaultNotificationSound]
|
||||||
.defaulting(to: Preferences.Sound.defaultNotificationSound)
|
.defaulting(to: Preferences.Sound.defaultNotificationSound)
|
||||||
|
@ -72,9 +70,9 @@ class NotificationSettingsViewModel: SessionTableViewModel<NoNav, NotificationSe
|
||||||
rightAccessory: .toggle(
|
rightAccessory: .toggle(
|
||||||
.userDefaults(UserDefaults.standard, key: "isUsingFullAPNs")
|
.userDefaults(UserDefaults.standard, key: "isUsingFullAPNs")
|
||||||
),
|
),
|
||||||
extraAction: SessionCell.ExtraAction(
|
styling: SessionCell.StyleInfo(
|
||||||
title: "NOTIFICATIONS_STRATEGY_FAST_MODE_ACTION".localized(),
|
allowedSeparators: [.top],
|
||||||
onTap: { UIApplication.shared.openSystemSettings() }
|
customPadding: SessionCell.Padding(bottom: Values.verySmallSpacing)
|
||||||
),
|
),
|
||||||
onTap: {
|
onTap: {
|
||||||
UserDefaults.standard.set(
|
UserDefaults.standard.set(
|
||||||
|
@ -85,6 +83,19 @@ class NotificationSettingsViewModel: SessionTableViewModel<NoNav, NotificationSe
|
||||||
// Force sync the push tokens on change
|
// Force sync the push tokens on change
|
||||||
SyncPushTokensJob.run(uploadOnlyIfStale: false)
|
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()
|
.removeDuplicates()
|
||||||
.publisher(in: Storage.shared)
|
.publisher(in: Storage.shared)
|
||||||
|
.mapToSessionTableViewData(for: self)
|
||||||
// MARK: - Functions
|
|
||||||
|
|
||||||
public override func updateSettings(_ updatedSettings: [SectionModel]) {
|
|
||||||
self._settingsData = updatedSettings
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -76,10 +76,7 @@ class NotificationSoundViewModel: SessionTableViewModel<NotificationSoundViewMod
|
||||||
|
|
||||||
override var title: String { "NOTIFICATIONS_STYLE_SOUND_TITLE".localized() }
|
override var title: String { "NOTIFICATIONS_STYLE_SOUND_TITLE".localized() }
|
||||||
|
|
||||||
private var _settingsData: [SectionModel] = []
|
public override var observableTableData: ObservableData { _observableTableData }
|
||||||
public override var settingsData: [SectionModel] { _settingsData }
|
|
||||||
|
|
||||||
public override var observableSettingsData: ObservableData { _observableSettingsData }
|
|
||||||
|
|
||||||
/// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise
|
/// 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
|
/// 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
|
/// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own
|
||||||
/// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`)
|
/// 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
|
/// 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
|
.trackingConstantRegion { [weak self] db -> [SectionModel] in
|
||||||
self?.storedSelection = try {
|
self?.storedSelection = try {
|
||||||
guard let threadId: String = self?.threadId else {
|
guard let threadId: String = self?.threadId else {
|
||||||
|
@ -150,12 +147,9 @@ class NotificationSoundViewModel: SessionTableViewModel<NotificationSoundViewMod
|
||||||
}
|
}
|
||||||
.removeDuplicates()
|
.removeDuplicates()
|
||||||
.publisher(in: Storage.shared)
|
.publisher(in: Storage.shared)
|
||||||
|
.mapToSessionTableViewData(for: self)
|
||||||
|
|
||||||
// MARK: - Functions
|
// MARK: - Functions
|
||||||
|
|
||||||
public override func updateSettings(_ updatedSettings: [SectionModel]) {
|
|
||||||
self._settingsData = updatedSettings
|
|
||||||
}
|
|
||||||
|
|
||||||
private func saveChanges() {
|
private func saveChanges() {
|
||||||
guard let currentSelection: Preferences.Sound = self.currentSelection.value else { return }
|
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 {
|
public enum Item: Differentiable {
|
||||||
|
@ -76,10 +76,7 @@ class PrivacySettingsViewModel: SessionTableViewModel<PrivacySettingsViewModel.N
|
||||||
|
|
||||||
override var title: String { "PRIVACY_TITLE".localized() }
|
override var title: String { "PRIVACY_TITLE".localized() }
|
||||||
|
|
||||||
private var _settingsData: [SectionModel] = []
|
public override var observableTableData: ObservableData { _observableTableData }
|
||||||
public override var settingsData: [SectionModel] { _settingsData }
|
|
||||||
|
|
||||||
public override var observableSettingsData: ObservableData { _observableSettingsData }
|
|
||||||
|
|
||||||
/// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise
|
/// 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
|
/// 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
|
/// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own
|
||||||
/// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`)
|
/// 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
|
/// 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
|
.trackingConstantRegion { db -> [SectionModel] in
|
||||||
return [
|
return [
|
||||||
SectionModel(
|
SectionModel(
|
||||||
|
@ -128,34 +125,40 @@ class PrivacySettingsViewModel: SessionTableViewModel<PrivacySettingsViewModel.N
|
||||||
elements: [
|
elements: [
|
||||||
SessionCell.Info(
|
SessionCell.Info(
|
||||||
id: .typingIndicators,
|
id: .typingIndicators,
|
||||||
title: "PRIVACY_TYPING_INDICATORS_TITLE".localized(),
|
title: SessionCell.TextInfo(
|
||||||
subtitle: "PRIVACY_TYPING_INDICATORS_DESCRIPTION".localized(),
|
"PRIVACY_TYPING_INDICATORS_TITLE".localized(),
|
||||||
subtitleExtraViewGenerator: {
|
font: .title
|
||||||
let targetHeight: CGFloat = 20
|
),
|
||||||
let targetWidth: CGFloat = ceil(20 * (targetHeight / 12))
|
subtitle: SessionCell.TextInfo(
|
||||||
let result: UIView = UIView(
|
"PRIVACY_TYPING_INDICATORS_DESCRIPTION".localized(),
|
||||||
frame: CGRect(x: 0, y: 0, width: targetWidth, height: targetHeight)
|
font: .subtitle,
|
||||||
)
|
extraViewGenerator: {
|
||||||
result.set(.width, to: targetWidth)
|
let targetHeight: CGFloat = 20
|
||||||
result.set(.height, to: targetHeight)
|
let targetWidth: CGFloat = ceil(20 * (targetHeight / 12))
|
||||||
|
let result: UIView = UIView(
|
||||||
// Use a transform scale to reduce the size of the typing indicator to the
|
frame: CGRect(x: 0, y: 0, width: targetWidth, height: targetHeight)
|
||||||
// desired size (this way the animation remains intact)
|
)
|
||||||
let cell: TypingIndicatorCell = TypingIndicatorCell()
|
result.set(.width, to: targetWidth)
|
||||||
cell.transform = CGAffineTransform.scale(targetHeight / cell.bounds.height)
|
result.set(.height, to: targetHeight)
|
||||||
cell.typingIndicatorView.startAnimation()
|
|
||||||
result.addSubview(cell)
|
// Use a transform scale to reduce the size of the typing indicator to the
|
||||||
|
// desired size (this way the animation remains intact)
|
||||||
// Note: Because we are messing with the transform these values don't work
|
let cell: TypingIndicatorCell = TypingIndicatorCell()
|
||||||
// logically so we inset the positioning to make it look visually centered
|
cell.transform = CGAffineTransform.scale(targetHeight / cell.bounds.height)
|
||||||
// within the layout inspector
|
cell.typingIndicatorView.startAnimation()
|
||||||
cell.center(.vertical, in: result, withInset: -(targetHeight * 0.15))
|
result.addSubview(cell)
|
||||||
cell.center(.horizontal, in: result, withInset: -(targetWidth * 0.35))
|
|
||||||
cell.set(.width, to: .width, of: result)
|
// Note: Because we are messing with the transform these values don't work
|
||||||
cell.set(.height, to: .height, of: result)
|
// logically so we inset the positioning to make it look visually centered
|
||||||
|
// within the layout inspector
|
||||||
return result
|
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)),
|
rightAccessory: .toggle(.settingBool(key: .typingIndicatorsEnabled)),
|
||||||
onTap: {
|
onTap: {
|
||||||
Storage.shared.write { db in
|
Storage.shared.write { db in
|
||||||
|
@ -189,7 +192,9 @@ class PrivacySettingsViewModel: SessionTableViewModel<PrivacySettingsViewModel.N
|
||||||
title: "PRIVACY_CALLS_TITLE".localized(),
|
title: "PRIVACY_CALLS_TITLE".localized(),
|
||||||
subtitle: "PRIVACY_CALLS_DESCRIPTION".localized(),
|
subtitle: "PRIVACY_CALLS_DESCRIPTION".localized(),
|
||||||
rightAccessory: .toggle(.settingBool(key: .areCallsEnabled)),
|
rightAccessory: .toggle(.settingBool(key: .areCallsEnabled)),
|
||||||
accessibilityLabel: "Allow voice and video calls",
|
accessibility: SessionCell.Accessibility(
|
||||||
|
label: "Allow voice and video calls"
|
||||||
|
),
|
||||||
confirmationInfo: ConfirmationModal.Info(
|
confirmationInfo: ConfirmationModal.Info(
|
||||||
title: "PRIVACY_CALLS_WARNING_TITLE".localized(),
|
title: "PRIVACY_CALLS_WARNING_TITLE".localized(),
|
||||||
explanation: "PRIVACY_CALLS_WARNING_DESCRIPTION".localized(),
|
explanation: "PRIVACY_CALLS_WARNING_DESCRIPTION".localized(),
|
||||||
|
@ -211,10 +216,5 @@ class PrivacySettingsViewModel: SessionTableViewModel<PrivacySettingsViewModel.N
|
||||||
}
|
}
|
||||||
.removeDuplicates()
|
.removeDuplicates()
|
||||||
.publisher(in: Storage.shared)
|
.publisher(in: Storage.shared)
|
||||||
|
.mapToSessionTableViewData(for: self)
|
||||||
// MARK: - Functions
|
|
||||||
|
|
||||||
public override func updateSettings(_ updatedSettings: [SectionModel]) {
|
|
||||||
self._settingsData = updatedSettings
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,12 +26,33 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
|
||||||
|
|
||||||
public enum Section: SessionTableSection {
|
public enum Section: SessionTableSection {
|
||||||
case profileInfo
|
case profileInfo
|
||||||
|
case sessionId
|
||||||
case menus
|
case menus
|
||||||
case footer
|
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 {
|
public enum Item: Differentiable {
|
||||||
case profileInfo
|
case avatar
|
||||||
|
case profileName
|
||||||
|
|
||||||
|
case sessionId
|
||||||
|
case idActions
|
||||||
|
|
||||||
case path
|
case path
|
||||||
case privacy
|
case privacy
|
||||||
case notifications
|
case notifications
|
||||||
|
@ -47,7 +68,18 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
|
||||||
// MARK: - Variables
|
// MARK: - Variables
|
||||||
|
|
||||||
private let userSessionId: String
|
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
|
fileprivate var oldDisplayName: String
|
||||||
private var editedDisplayName: String?
|
private var editedDisplayName: String?
|
||||||
|
|
||||||
|
@ -63,8 +95,20 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
|
||||||
// MARK: - Navigation
|
// MARK: - Navigation
|
||||||
|
|
||||||
lazy var navState: AnyPublisher<NavState, Never> = {
|
lazy var navState: AnyPublisher<NavState, Never> = {
|
||||||
isEditing
|
Publishers
|
||||||
.map { isEditing in (isEditing ? .editing : .standard) }
|
.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()
|
.removeDuplicates()
|
||||||
.prepend(.standard) // Initial value
|
.prepend(.standard) // Initial value
|
||||||
.eraseToAnyPublisher()
|
.eraseToAnyPublisher()
|
||||||
|
@ -176,10 +220,7 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
|
||||||
|
|
||||||
override var title: String { "vc_settings_title".localized() }
|
override var title: String { "vc_settings_title".localized() }
|
||||||
|
|
||||||
private var _settingsData: [SectionModel] = []
|
public override var observableTableData: ObservableData { _observableTableData }
|
||||||
public override var settingsData: [SectionModel] { _settingsData }
|
|
||||||
|
|
||||||
public override var observableSettingsData: ObservableData { _observableSettingsData }
|
|
||||||
|
|
||||||
/// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise
|
/// 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
|
/// 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
|
/// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own
|
||||||
/// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`)
|
/// 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
|
/// 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
|
.trackingConstantRegion { [weak self] db -> [SectionModel] in
|
||||||
let userPublicKey: String = getUserHexEncodedPublicKey(db)
|
let userPublicKey: String = getUserHexEncodedPublicKey(db)
|
||||||
let profile: Profile = Profile.fetchOrCreateCurrentUser(db)
|
let profile: Profile = Profile.fetchOrCreateCurrentUser(db)
|
||||||
|
|
||||||
|
@ -198,38 +239,82 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
|
||||||
model: .profileInfo,
|
model: .profileInfo,
|
||||||
elements: [
|
elements: [
|
||||||
SessionCell.Info(
|
SessionCell.Info(
|
||||||
id: .profileInfo,
|
id: .avatar,
|
||||||
leftAccessory: .threadInfo(
|
accessory: .profile(
|
||||||
threadViewModel: SessionThreadViewModel(
|
id: profile.id,
|
||||||
threadId: profile.id,
|
size: .extraLarge,
|
||||||
threadIsNoteToSelf: true,
|
profile: profile
|
||||||
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 }
|
|
||||||
),
|
),
|
||||||
title: profile.displayName(),
|
styling: SessionCell.StyleInfo(
|
||||||
shouldHaveBackground: false
|
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: [
|
elements: [
|
||||||
SessionCell.Info(
|
SessionCell.Info(
|
||||||
id: .path,
|
id: .path,
|
||||||
leftAccessory: .customView {
|
leftAccessory: .customView(hashValue: "PathStatusView") {
|
||||||
// Need to ensure this view is the same size as the icons so
|
// Need to ensure this view is the same size as the icons so
|
||||||
// wrap it in a larger view
|
// wrap it in a larger view
|
||||||
let result: UIView = UIView()
|
let result: UIView = UIView()
|
||||||
|
@ -252,7 +337,7 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
|
||||||
return result
|
return result
|
||||||
},
|
},
|
||||||
title: "vc_path_title".localized(),
|
title: "vc_path_title".localized(),
|
||||||
onTap: { [weak self] in self?.transitionToScreen(PathVC()) }
|
onTap: { self?.transitionToScreen(PathVC()) }
|
||||||
),
|
),
|
||||||
SessionCell.Info(
|
SessionCell.Info(
|
||||||
id: .privacy,
|
id: .privacy,
|
||||||
|
@ -261,7 +346,7 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
|
||||||
.withRenderingMode(.alwaysTemplate)
|
.withRenderingMode(.alwaysTemplate)
|
||||||
),
|
),
|
||||||
title: "vc_settings_privacy_button_title".localized(),
|
title: "vc_settings_privacy_button_title".localized(),
|
||||||
onTap: { [weak self] in
|
onTap: {
|
||||||
self?.transitionToScreen(
|
self?.transitionToScreen(
|
||||||
SessionTableViewController(viewModel: PrivacySettingsViewModel())
|
SessionTableViewController(viewModel: PrivacySettingsViewModel())
|
||||||
)
|
)
|
||||||
|
@ -274,7 +359,7 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
|
||||||
.withRenderingMode(.alwaysTemplate)
|
.withRenderingMode(.alwaysTemplate)
|
||||||
),
|
),
|
||||||
title: "vc_settings_notifications_button_title".localized(),
|
title: "vc_settings_notifications_button_title".localized(),
|
||||||
onTap: { [weak self] in
|
onTap: {
|
||||||
self?.transitionToScreen(
|
self?.transitionToScreen(
|
||||||
SessionTableViewController(viewModel: NotificationSettingsViewModel())
|
SessionTableViewController(viewModel: NotificationSettingsViewModel())
|
||||||
)
|
)
|
||||||
|
@ -287,7 +372,7 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
|
||||||
.withRenderingMode(.alwaysTemplate)
|
.withRenderingMode(.alwaysTemplate)
|
||||||
),
|
),
|
||||||
title: "CONVERSATION_SETTINGS_TITLE".localized(),
|
title: "CONVERSATION_SETTINGS_TITLE".localized(),
|
||||||
onTap: { [weak self] in
|
onTap: {
|
||||||
self?.transitionToScreen(
|
self?.transitionToScreen(
|
||||||
SessionTableViewController(viewModel: ConversationSettingsViewModel())
|
SessionTableViewController(viewModel: ConversationSettingsViewModel())
|
||||||
)
|
)
|
||||||
|
@ -300,7 +385,7 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
|
||||||
.withRenderingMode(.alwaysTemplate)
|
.withRenderingMode(.alwaysTemplate)
|
||||||
),
|
),
|
||||||
title: "MESSAGE_REQUESTS_TITLE".localized(),
|
title: "MESSAGE_REQUESTS_TITLE".localized(),
|
||||||
onTap: { [weak self] in
|
onTap: {
|
||||||
self?.transitionToScreen(MessageRequestsViewController())
|
self?.transitionToScreen(MessageRequestsViewController())
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
|
@ -311,7 +396,7 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
|
||||||
.withRenderingMode(.alwaysTemplate)
|
.withRenderingMode(.alwaysTemplate)
|
||||||
),
|
),
|
||||||
title: "APPEARANCE_TITLE".localized(),
|
title: "APPEARANCE_TITLE".localized(),
|
||||||
onTap: { [weak self] in
|
onTap: {
|
||||||
self?.transitionToScreen(AppearanceViewController())
|
self?.transitionToScreen(AppearanceViewController())
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
|
@ -322,7 +407,7 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
|
||||||
.withRenderingMode(.alwaysTemplate)
|
.withRenderingMode(.alwaysTemplate)
|
||||||
),
|
),
|
||||||
title: "vc_settings_invite_a_friend_button_title".localized(),
|
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) !"
|
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(
|
self?.transitionToScreen(
|
||||||
|
@ -341,7 +426,7 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
|
||||||
.withRenderingMode(.alwaysTemplate)
|
.withRenderingMode(.alwaysTemplate)
|
||||||
),
|
),
|
||||||
title: "vc_settings_recovery_phrase_button_title".localized(),
|
title: "vc_settings_recovery_phrase_button_title".localized(),
|
||||||
onTap: { [weak self] in
|
onTap: {
|
||||||
self?.transitionToScreen(SeedModal(), transitionType: .present)
|
self?.transitionToScreen(SeedModal(), transitionType: .present)
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
|
@ -352,7 +437,7 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
|
||||||
.withRenderingMode(.alwaysTemplate)
|
.withRenderingMode(.alwaysTemplate)
|
||||||
),
|
),
|
||||||
title: "HELP_TITLE".localized(),
|
title: "HELP_TITLE".localized(),
|
||||||
onTap: { [weak self] in
|
onTap: {
|
||||||
self?.transitionToScreen(
|
self?.transitionToScreen(
|
||||||
SessionTableViewController(viewModel: HelpViewModel())
|
SessionTableViewController(viewModel: HelpViewModel())
|
||||||
)
|
)
|
||||||
|
@ -365,8 +450,8 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
|
||||||
.withRenderingMode(.alwaysTemplate)
|
.withRenderingMode(.alwaysTemplate)
|
||||||
),
|
),
|
||||||
title: "vc_settings_clear_all_data_button_title".localized(),
|
title: "vc_settings_clear_all_data_button_title".localized(),
|
||||||
tintColor: .danger,
|
styling: SessionCell.StyleInfo(tintColor: .danger),
|
||||||
onTap: { [weak self] in
|
onTap: {
|
||||||
self?.transitionToScreen(NukeDataModal(), transitionType: .present)
|
self?.transitionToScreen(NukeDataModal(), transitionType: .present)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
@ -376,6 +461,7 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
|
||||||
}
|
}
|
||||||
.removeDuplicates()
|
.removeDuplicates()
|
||||||
.publisher(in: Storage.shared)
|
.publisher(in: Storage.shared)
|
||||||
|
.mapToSessionTableViewData(for: self)
|
||||||
|
|
||||||
public override var footerView: AnyPublisher<UIView?, Never> {
|
public override var footerView: AnyPublisher<UIView?, Never> {
|
||||||
Just(VersionFooterView())
|
Just(VersionFooterView())
|
||||||
|
@ -383,26 +469,30 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Functions
|
// MARK: - Functions
|
||||||
|
|
||||||
public override func updateSettings(_ updatedSettings: [SectionModel]) {
|
|
||||||
self._settingsData = updatedSettings
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateProfilePicture() {
|
private func updateProfilePicture(hasCustomImage: Bool) {
|
||||||
let actionSheet: UIAlertController = UIAlertController(
|
let actionSheet: UIAlertController = UIAlertController(
|
||||||
title: "Update Profile Picture",
|
title: "Update Profile Picture",
|
||||||
message: nil,
|
message: nil,
|
||||||
preferredStyle: .actionSheet
|
preferredStyle: .actionSheet
|
||||||
)
|
)
|
||||||
let action = UIAlertAction(
|
actionSheet.addAction(UIAlertAction(
|
||||||
title: "MEDIA_FROM_LIBRARY_BUTTON".localized(),
|
title: "MEDIA_FROM_LIBRARY_BUTTON".localized(),
|
||||||
style: .default,
|
style: .default,
|
||||||
handler: { [weak self] _ in
|
handler: { [weak self] _ in
|
||||||
self?.showPhotoLibraryForAvatar()
|
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))
|
actionSheet.addAction(UIAlertAction(title: "cancel".localized(), style: .cancel, handler: nil))
|
||||||
|
|
||||||
self.transitionToScreen(actionSheet, transitionType: .present)
|
self.transitionToScreen(actionSheet, transitionType: .present)
|
||||||
|
|
|
@ -81,8 +81,10 @@ class BlockedContactCell: UITableViewCell {
|
||||||
public func update(with cellViewModel: BlockedContactsViewModel.DataModel, isSelected: Bool) {
|
public func update(with cellViewModel: BlockedContactsViewModel.DataModel, isSelected: Bool) {
|
||||||
profilePictureView.update(
|
profilePictureView.update(
|
||||||
publicKey: cellViewModel.profile.id,
|
publicKey: cellViewModel.profile.id,
|
||||||
|
threadVariant: .contact,
|
||||||
|
customImageData: nil,
|
||||||
profile: cellViewModel.profile,
|
profile: cellViewModel.profile,
|
||||||
threadVariant: .contact
|
additionalProfile: nil
|
||||||
)
|
)
|
||||||
selectionView.text = cellViewModel.profile.displayName()
|
selectionView.text = cellViewModel.profile.displayName()
|
||||||
selectionView.update(isSelected: isSelected)
|
selectionView.update(isSelected: isSelected)
|
||||||
|
|
|
@ -232,11 +232,10 @@ public final class FullConversationCell: UITableViewCell {
|
||||||
public func updateForMessageSearchResult(with cellViewModel: SessionThreadViewModel, searchText: String) {
|
public func updateForMessageSearchResult(with cellViewModel: SessionThreadViewModel, searchText: String) {
|
||||||
profilePictureView.update(
|
profilePictureView.update(
|
||||||
publicKey: cellViewModel.threadId,
|
publicKey: cellViewModel.threadId,
|
||||||
profile: cellViewModel.profile,
|
|
||||||
additionalProfile: cellViewModel.additionalProfile,
|
|
||||||
threadVariant: cellViewModel.threadVariant,
|
threadVariant: cellViewModel.threadVariant,
|
||||||
openGroupProfilePictureData: cellViewModel.openGroupProfilePictureData,
|
customImageData: cellViewModel.openGroupProfilePictureData,
|
||||||
useFallbackPicture: (cellViewModel.threadVariant == .openGroup && cellViewModel.openGroupProfilePictureData == nil)
|
profile: cellViewModel.profile,
|
||||||
|
additionalProfile: cellViewModel.additionalProfile
|
||||||
)
|
)
|
||||||
|
|
||||||
isPinnedIcon.isHidden = true
|
isPinnedIcon.isHidden = true
|
||||||
|
@ -283,11 +282,10 @@ public final class FullConversationCell: UITableViewCell {
|
||||||
public func updateForContactAndGroupSearchResult(with cellViewModel: SessionThreadViewModel, searchText: String) {
|
public func updateForContactAndGroupSearchResult(with cellViewModel: SessionThreadViewModel, searchText: String) {
|
||||||
profilePictureView.update(
|
profilePictureView.update(
|
||||||
publicKey: cellViewModel.threadId,
|
publicKey: cellViewModel.threadId,
|
||||||
profile: cellViewModel.profile,
|
|
||||||
additionalProfile: cellViewModel.additionalProfile,
|
|
||||||
threadVariant: cellViewModel.threadVariant,
|
threadVariant: cellViewModel.threadVariant,
|
||||||
openGroupProfilePictureData: cellViewModel.openGroupProfilePictureData,
|
customImageData: cellViewModel.openGroupProfilePictureData,
|
||||||
useFallbackPicture: (cellViewModel.threadVariant == .openGroup && cellViewModel.openGroupProfilePictureData == nil)
|
profile: cellViewModel.profile,
|
||||||
|
additionalProfile: cellViewModel.additionalProfile
|
||||||
)
|
)
|
||||||
|
|
||||||
isPinnedIcon.isHidden = true
|
isPinnedIcon.isHidden = true
|
||||||
|
@ -362,15 +360,10 @@ public final class FullConversationCell: UITableViewCell {
|
||||||
)
|
)
|
||||||
profilePictureView.update(
|
profilePictureView.update(
|
||||||
publicKey: cellViewModel.threadId,
|
publicKey: cellViewModel.threadId,
|
||||||
profile: cellViewModel.profile,
|
|
||||||
additionalProfile: cellViewModel.additionalProfile,
|
|
||||||
threadVariant: cellViewModel.threadVariant,
|
threadVariant: cellViewModel.threadVariant,
|
||||||
openGroupProfilePictureData: cellViewModel.openGroupProfilePictureData,
|
customImageData: cellViewModel.openGroupProfilePictureData,
|
||||||
useFallbackPicture: (
|
profile: cellViewModel.profile,
|
||||||
cellViewModel.threadVariant == .openGroup &&
|
additionalProfile: cellViewModel.additionalProfile
|
||||||
cellViewModel.openGroupProfilePictureData == nil
|
|
||||||
),
|
|
||||||
showMultiAvatarForClosedGroup: true
|
|
||||||
)
|
)
|
||||||
displayNameLabel.text = cellViewModel.displayName
|
displayNameLabel.text = cellViewModel.displayName
|
||||||
timestampLabel.text = cellViewModel.lastInteractionDate.formattedForDisplay
|
timestampLabel.text = cellViewModel.lastInteractionDate.formattedForDisplay
|
||||||
|
|
|
@ -16,10 +16,14 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
|
||||||
typealias SectionModel = SessionTableViewModel<NavItemId, Section, SettingItem>.SectionModel
|
typealias SectionModel = SessionTableViewModel<NavItemId, Section, SettingItem>.SectionModel
|
||||||
|
|
||||||
private let viewModel: SessionTableViewModel<NavItemId, Section, SettingItem>
|
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 dataStreamJustFailed: Bool = false
|
||||||
private var dataChangeCancellable: AnyCancellable?
|
private var dataChangeCancellable: AnyCancellable?
|
||||||
private var disposables: Set<AnyCancellable> = Set()
|
private var disposables: Set<AnyCancellable> = Set()
|
||||||
|
private var onFooterTap: (() -> ())?
|
||||||
|
|
||||||
public var viewModelType: AnyObject.Type { return type(of: viewModel) }
|
public var viewModelType: AnyObject.Type { return type(of: viewModel) }
|
||||||
|
|
||||||
|
@ -32,7 +36,6 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
|
||||||
result.themeBackgroundColor = .clear
|
result.themeBackgroundColor = .clear
|
||||||
result.showsVerticalScrollIndicator = false
|
result.showsVerticalScrollIndicator = false
|
||||||
result.showsHorizontalScrollIndicator = false
|
result.showsHorizontalScrollIndicator = false
|
||||||
result.register(view: SessionAvatarCell.self)
|
|
||||||
result.register(view: SessionCell.self)
|
result.register(view: SessionCell.self)
|
||||||
result.registerHeaderFooterView(view: SessionHeaderView.self)
|
result.registerHeaderFooterView(view: SessionHeaderView.self)
|
||||||
result.dataSource = self
|
result.dataSource = self
|
||||||
|
@ -45,11 +48,50 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
|
||||||
return result
|
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
|
// MARK: - Initialization
|
||||||
|
|
||||||
init(viewModel: SessionTableViewModel<NavItemId, Section, SettingItem>) {
|
init(viewModel: SessionTableViewModel<NavItemId, Section, SettingItem>) {
|
||||||
self.viewModel = viewModel
|
self.viewModel = viewModel
|
||||||
|
|
||||||
|
Storage.shared.addObserver(viewModel.pagedDataObserver)
|
||||||
|
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -74,6 +116,9 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
|
||||||
|
|
||||||
view.themeBackgroundColor = .backgroundPrimary
|
view.themeBackgroundColor = .backgroundPrimary
|
||||||
view.addSubview(tableView)
|
view.addSubview(tableView)
|
||||||
|
view.addSubview(emptyStateLabel)
|
||||||
|
view.addSubview(fadeView)
|
||||||
|
view.addSubview(footerButton)
|
||||||
|
|
||||||
setupLayout()
|
setupLayout()
|
||||||
setupBinding()
|
setupBinding()
|
||||||
|
@ -98,6 +143,13 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
|
||||||
startObservingChanges()
|
startObservingChanges()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
|
super.viewDidAppear(animated)
|
||||||
|
|
||||||
|
viewHasAppeared = true
|
||||||
|
autoLoadNextPageIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
override func viewWillDisappear(_ animated: Bool) {
|
override func viewWillDisappear(_ animated: Bool) {
|
||||||
super.viewWillDisappear(animated)
|
super.viewWillDisappear(animated)
|
||||||
|
|
||||||
|
@ -114,18 +166,29 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
|
||||||
|
|
||||||
private func setupLayout() {
|
private func setupLayout() {
|
||||||
tableView.pin(to: view)
|
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
|
// MARK: - Updating
|
||||||
|
|
||||||
private func startObservingChanges() {
|
private func startObservingChanges() {
|
||||||
// Start observing for data changes
|
// Start observing for data changes
|
||||||
dataChangeCancellable = viewModel.observableSettingsData
|
dataChangeCancellable = viewModel.observableTableData
|
||||||
.receiveOnMain(
|
.receiveOnMain(
|
||||||
// If we haven't done the initial load the trigger it immediately (blocking the main
|
// 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
|
// thread so we remain on the launch screen until it completes to be consistent with
|
||||||
// the old behaviour)
|
// the old behaviour)
|
||||||
immediately: !hasLoadedInitialSettingsData
|
immediately: !hasLoadedInitialTableData
|
||||||
)
|
)
|
||||||
.sink(
|
.sink(
|
||||||
receiveCompletion: { [weak self] result in
|
receiveCompletion: { [weak self] result in
|
||||||
|
@ -146,9 +209,9 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
|
||||||
case .finished: break
|
case .finished: break
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
receiveValue: { [weak self] settingsData in
|
receiveValue: { [weak self] updatedData, changeset in
|
||||||
self?.dataStreamJustFailed = false
|
self?.dataStreamJustFailed = false
|
||||||
self?.handleSettingsUpdates(settingsData)
|
self?.handleDataUpdates(updatedData, changeset: changeset)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -158,27 +221,80 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
|
||||||
dataChangeCancellable?.cancel()
|
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
|
// Ensure the first load runs without animations (if we don't do this the cells will animate
|
||||||
// in from a frame of CGRect.zero)
|
// in from a frame of CGRect.zero)
|
||||||
guard hasLoadedInitialSettingsData else {
|
guard hasLoadedInitialTableData else {
|
||||||
hasLoadedInitialSettingsData = true
|
hasLoadedInitialTableData = true
|
||||||
UIView.performWithoutAnimation { handleSettingsUpdates(updatedData, initialLoad: true) }
|
UIView.performWithoutAnimation {
|
||||||
|
handleDataUpdates(updatedData, changeset: changeset, initialLoad: true)
|
||||||
|
}
|
||||||
return
|
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)
|
// Reload the table content (animate changes after the first load)
|
||||||
tableView.reload(
|
tableView.reload(
|
||||||
using: StagedChangeset(source: viewModel.settingsData, target: updatedData),
|
using: changeset,
|
||||||
deleteSectionsAnimation: .none,
|
deleteSectionsAnimation: .none,
|
||||||
insertSectionsAnimation: .none,
|
insertSectionsAnimation: .none,
|
||||||
reloadSectionsAnimation: .none,
|
reloadSectionsAnimation: .none,
|
||||||
deleteRowsAnimation: .bottom,
|
deleteRowsAnimation: .fade,
|
||||||
insertRowsAnimation: .none,
|
insertRowsAnimation: .fade,
|
||||||
reloadRowsAnimation: .none,
|
reloadRowsAnimation: .fade,
|
||||||
interrupt: { $0.changeCount > 100 } // Prevent too many changes from causing performance issues
|
interrupt: { $0.changeCount > 100 } // Prevent too many changes from causing performance issues
|
||||||
) { [weak self] updatedData in
|
) { [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
|
viewModel.isEditing
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] isEditing in
|
.sink { [weak self] isEditing in
|
||||||
self?.setEditing(isEditing, animated: true)
|
UIView.animate(withDuration: 0.25) {
|
||||||
|
self?.setEditing(isEditing, animated: true)
|
||||||
self?.tableView.visibleCells.forEach { cell in
|
|
||||||
switch cell {
|
self?.tableView.visibleCells
|
||||||
case let cell as SessionCell:
|
.compactMap { $0 as? SessionCell }
|
||||||
cell.update(isEditing: isEditing, animated: true)
|
.filter { $0.interactionMode == .editable || $0.interactionMode == .alwaysEditing }
|
||||||
|
.enumerated()
|
||||||
case let avatarCell as SessionAvatarCell:
|
.forEach { index, cell in
|
||||||
avatarCell.update(isEditing: isEditing, animated: true)
|
cell.update(
|
||||||
|
isEditing: (isEditing || cell.interactionMode == .alwaysEditing),
|
||||||
default: break
|
becomeFirstResponder: (
|
||||||
}
|
isEditing &&
|
||||||
|
index == 0 &&
|
||||||
|
cell.interactionMode != .alwaysEditing
|
||||||
|
),
|
||||||
|
animated: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
self?.tableView.beginUpdates()
|
||||||
|
self?.tableView.endUpdates()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.store(in: &disposables)
|
.store(in: &disposables)
|
||||||
|
@ -248,6 +373,13 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
|
||||||
}
|
}
|
||||||
.store(in: &disposables)
|
.store(in: &disposables)
|
||||||
|
|
||||||
|
viewModel.emptyStateTextPublisher
|
||||||
|
.receiveOnMain(immediately: true)
|
||||||
|
.sink { [weak self] text in
|
||||||
|
self?.emptyStateLabel.text = text
|
||||||
|
}
|
||||||
|
.store(in: &disposables)
|
||||||
|
|
||||||
viewModel.footerView
|
viewModel.footerView
|
||||||
.receiveOnMain(immediately: true)
|
.receiveOnMain(immediately: true)
|
||||||
.sink { [weak self] footerView in
|
.sink { [weak self] footerView in
|
||||||
|
@ -255,6 +387,33 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
|
||||||
}
|
}
|
||||||
.store(in: &disposables)
|
.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
|
viewModel.showToast
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] text, color in
|
.sink { [weak self] text, color in
|
||||||
|
@ -303,91 +462,60 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
|
||||||
|
|
||||||
case .dismiss: self?.dismiss(animated: true)
|
case .dismiss: self?.dismiss(animated: true)
|
||||||
case .pop: self?.navigationController?.popViewController(animated: true)
|
case .pop: self?.navigationController?.popViewController(animated: true)
|
||||||
|
case .popToRoot: self?.navigationController?.popToRootViewController(animated: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.store(in: &disposables)
|
.store(in: &disposables)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc private func footerButtonTapped() {
|
||||||
|
onFooterTap?()
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - UITableViewDataSource
|
// MARK: - UITableViewDataSource
|
||||||
|
|
||||||
func numberOfSections(in tableView: UITableView) -> Int {
|
func numberOfSections(in tableView: UITableView) -> Int {
|
||||||
return self.viewModel.settingsData.count
|
return self.viewModel.tableData.count
|
||||||
}
|
}
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
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 {
|
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 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 {
|
return cell
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
|
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 {
|
return result
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - UITableViewDelegate
|
// MARK: - UITableViewDelegate
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
|
func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
|
||||||
let section: SectionModel = viewModel.settingsData[section]
|
return viewModel.tableData[section].model.style.height
|
||||||
|
|
||||||
switch section.model.style {
|
|
||||||
case .none: return 0
|
|
||||||
case .padding, .title: return UITableView.automaticDimension
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
|
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 {
|
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||||
return UITableView.automaticDimension
|
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) {
|
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
tableView.deselectRow(at: indexPath, animated: true)
|
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]
|
let info: SessionCell.Info<SettingItem> = section.elements[indexPath.row]
|
||||||
|
|
||||||
// Do nothing if the item is disabled
|
// Do nothing if the item is disabled
|
||||||
|
@ -414,10 +559,10 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (info.leftAccessory, info.rightAccessory) {
|
switch (info.leftAccessory, info.rightAccessory) {
|
||||||
case (_, .highlightingBackgroundLabel(_)):
|
case (_, .highlightingBackgroundLabel(_, _)):
|
||||||
return (!cell.rightAccessoryView.isHidden ? cell.rightAccessoryView : cell)
|
return (!cell.rightAccessoryView.isHidden ? cell.rightAccessoryView : cell)
|
||||||
|
|
||||||
case (.highlightingBackgroundLabel(_), _):
|
case (.highlightingBackgroundLabel(_, _), _):
|
||||||
return (!cell.leftAccessoryView.isHidden ? cell.leftAccessoryView : cell)
|
return (!cell.leftAccessoryView.isHidden ? cell.leftAccessoryView : cell)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
@ -428,14 +573,15 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
|
||||||
.enumerated()
|
.enumerated()
|
||||||
.first(where: { index, info in
|
.first(where: { index, info in
|
||||||
switch (info.leftAccessory, info.rightAccessory) {
|
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
|
default: return false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
let performAction: () -> Void = { [weak self, weak tappedView] in
|
let performAction: () -> Void = { [weak self, weak tappedView] in
|
||||||
info.onTap?(tappedView)
|
info.onTap?()
|
||||||
|
info.onTapView?(tappedView)
|
||||||
self?.manuallyReload(indexPath: indexPath, section: section, info: info)
|
self?.manuallyReload(indexPath: indexPath, section: section, info: info)
|
||||||
|
|
||||||
// Update the old selection as well
|
// Update the old selection as well
|
||||||
|
@ -463,10 +609,6 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
|
||||||
let confirmationModal: ConfirmationModal = ConfirmationModal(
|
let confirmationModal: ConfirmationModal = ConfirmationModal(
|
||||||
targetView: tappedView,
|
targetView: tappedView,
|
||||||
info: confirmationInfo
|
info: confirmationInfo
|
||||||
.with(onConfirm: { [weak self] _ in
|
|
||||||
performAction()
|
|
||||||
self?.dismiss(animated: true)
|
|
||||||
})
|
|
||||||
)
|
)
|
||||||
present(confirmationModal, animated: true, completion: nil)
|
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
|
// 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 {
|
if let existingCell: SessionCell = tableView.cellForRow(at: indexPath) as? SessionCell {
|
||||||
existingCell.update(
|
existingCell.update(with: info)
|
||||||
with: info,
|
|
||||||
style: .rounded,
|
|
||||||
position: Position.with(indexPath.row, count: section.elements.count)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
tableView.reloadRows(at: [indexPath], with: .none)
|
tableView.reloadRows(at: [indexPath], with: .none)
|
||||||
|
|
|
@ -10,7 +10,7 @@ import SessionUtilitiesKit
|
||||||
|
|
||||||
class SessionTableViewModel<NavItemId: Equatable, Section: SessionTableSection, SettingItem: Hashable & Differentiable> {
|
class SessionTableViewModel<NavItemId: Equatable, Section: SessionTableSection, SettingItem: Hashable & Differentiable> {
|
||||||
typealias SectionModel = ArraySection<Section, SessionCell.Info<SettingItem>>
|
typealias SectionModel = ArraySection<Section, SessionCell.Info<SettingItem>>
|
||||||
typealias ObservableData = AnyPublisher<[SectionModel], Error>
|
typealias ObservableData = AnyPublisher<([SectionModel], StagedChangeset<[SectionModel]>), Error>
|
||||||
|
|
||||||
// MARK: - Input
|
// MARK: - Input
|
||||||
|
|
||||||
|
@ -18,6 +18,9 @@ class SessionTableViewModel<NavItemId: Equatable, Section: SessionTableSection,
|
||||||
lazy var isEditing: AnyPublisher<Bool, Never> = _isEditing
|
lazy var isEditing: AnyPublisher<Bool, Never> = _isEditing
|
||||||
.removeDuplicates()
|
.removeDuplicates()
|
||||||
.shareReplay(1)
|
.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
|
// MARK: - Navigation
|
||||||
|
|
||||||
|
@ -37,15 +40,25 @@ class SessionTableViewModel<NavItemId: Equatable, Section: SessionTableSection,
|
||||||
// MARK: - Content
|
// MARK: - Content
|
||||||
|
|
||||||
open var title: String { preconditionFailure("abstract class - override in subclass") }
|
open var title: String { preconditionFailure("abstract class - override in subclass") }
|
||||||
open var settingsData: [SectionModel] { preconditionFailure("abstract class - override in subclass") }
|
open var emptyStateTextPublisher: AnyPublisher<String?, Never> { Just(nil).eraseToAnyPublisher() }
|
||||||
open var observableSettingsData: ObservableData {
|
|
||||||
preconditionFailure("abstract class - override in subclass")
|
|
||||||
}
|
|
||||||
open var footerView: AnyPublisher<UIView?, 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")
|
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
|
// MARK: - Functions
|
||||||
|
|
||||||
|
@ -53,6 +66,10 @@ class SessionTableViewModel<NavItemId: Equatable, Section: SessionTableSection,
|
||||||
_isEditing.send(isEditing)
|
_isEditing.send(isEditing)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func textChanged(_ text: String?, for item: SettingItem) {
|
||||||
|
_textChanged.send((text, item))
|
||||||
|
}
|
||||||
|
|
||||||
func showToast(text: String, backgroundColor: ThemeValue = .backgroundPrimary) {
|
func showToast(text: String, backgroundColor: ThemeValue = .backgroundPrimary) {
|
||||||
_showToast.send((text, backgroundColor))
|
_showToast.send((text, backgroundColor))
|
||||||
}
|
}
|
||||||
|
@ -65,3 +82,49 @@ class SessionTableViewModel<NavItemId: Equatable, Section: SessionTableSection,
|
||||||
_transitionToScreen.send((viewController, transitionType))
|
_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)
|
/// This will only trigger a `popViewController` call (if the screen was presented it'll do nothing)
|
||||||
case pop
|
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
|
/// 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)
|
/// the navigation controller, otherwise this will do nothing)
|
||||||
case dismiss
|
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?,
|
UIImage?,
|
||||||
size: IconSize,
|
size: IconSize,
|
||||||
customTint: ThemeValue?,
|
customTint: ThemeValue?,
|
||||||
shouldFill: Bool
|
shouldFill: Bool,
|
||||||
|
accessibility: SessionCell.Accessibility?
|
||||||
)
|
)
|
||||||
case iconAsync(
|
case iconAsync(
|
||||||
size: IconSize,
|
size: IconSize,
|
||||||
customTint: ThemeValue?,
|
customTint: ThemeValue?,
|
||||||
shouldFill: Bool,
|
shouldFill: Bool,
|
||||||
|
accessibility: SessionCell.Accessibility?,
|
||||||
setter: (UIImageView) -> Void
|
setter: (UIImageView) -> Void
|
||||||
)
|
)
|
||||||
case toggle(DataSource)
|
case toggle(
|
||||||
case dropDown(DataSource)
|
DataSource,
|
||||||
|
accessibility: SessionCell.Accessibility?
|
||||||
|
)
|
||||||
|
case dropDown(
|
||||||
|
DataSource,
|
||||||
|
accessibility: SessionCell.Accessibility?
|
||||||
|
)
|
||||||
case radio(
|
case radio(
|
||||||
size: RadioSize,
|
size: RadioSize,
|
||||||
isSelected: () -> Bool,
|
isSelected: () -> Bool,
|
||||||
storedSelection: Bool
|
storedSelection: Bool,
|
||||||
|
accessibility: SessionCell.Accessibility?
|
||||||
)
|
)
|
||||||
|
|
||||||
case highlightingBackgroundLabel(title: String)
|
case highlightingBackgroundLabel(
|
||||||
case profile(String, Profile?)
|
title: String,
|
||||||
case customView(viewGenerator: () -> UIView)
|
accessibility: SessionCell.Accessibility?
|
||||||
case threadInfo(
|
)
|
||||||
threadViewModel: SessionThreadViewModel,
|
case profile(
|
||||||
style: ThreadInfoStyle = ThreadInfoStyle(),
|
id: String,
|
||||||
avatarTapped: (() -> Void)? = nil,
|
size: IconSize,
|
||||||
titleTapped: (() -> Void)? = nil,
|
threadVariant: SessionThread.Variant,
|
||||||
titleChanged: ((String) -> Void)? = nil
|
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
|
// MARK: - Convenience Vatiables
|
||||||
|
|
||||||
var shouldFitToEdge: Bool {
|
var shouldFitToEdge: Bool {
|
||||||
switch self {
|
switch self {
|
||||||
case .icon(_, _, _, let shouldFill), .iconAsync(_, _, let shouldFill, _): return shouldFill
|
case .icon(_, _, _, let shouldFill, _), .iconAsync(_, _, let shouldFill, _, _):
|
||||||
|
return shouldFill
|
||||||
default: return false
|
default: return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var currentBoolValue: Bool {
|
var currentBoolValue: Bool {
|
||||||
switch self {
|
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
|
default: return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -59,90 +90,166 @@ extension SessionCell {
|
||||||
|
|
||||||
public func hash(into hasher: inout Hasher) {
|
public func hash(into hasher: inout Hasher) {
|
||||||
switch self {
|
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)
|
image.hash(into: &hasher)
|
||||||
size.hash(into: &hasher)
|
size.hash(into: &hasher)
|
||||||
customTint.hash(into: &hasher)
|
customTint.hash(into: &hasher)
|
||||||
shouldFill.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)
|
size.hash(into: &hasher)
|
||||||
customTint.hash(into: &hasher)
|
customTint.hash(into: &hasher)
|
||||||
shouldFill.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)
|
dataSource.hash(into: &hasher)
|
||||||
|
accessibility.hash(into: &hasher)
|
||||||
|
|
||||||
case .dropDown(let dataSource):
|
case .dropDown(let dataSource, let accessibility):
|
||||||
dataSource.hash(into: &hasher)
|
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)
|
size.hash(into: &hasher)
|
||||||
isSelected().hash(into: &hasher)
|
isSelected().hash(into: &hasher)
|
||||||
storedSelection.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)
|
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)
|
profileId.hash(into: &hasher)
|
||||||
|
size.hash(into: &hasher)
|
||||||
|
threadVariant.hash(into: &hasher)
|
||||||
|
customImageData.hash(into: &hasher)
|
||||||
profile.hash(into: &hasher)
|
profile.hash(into: &hasher)
|
||||||
|
additionalProfile.hash(into: &hasher)
|
||||||
|
cornerIcon.hash(into: &hasher)
|
||||||
|
accessibility.hash(into: &hasher)
|
||||||
|
|
||||||
case .customView: break
|
case .search(let placeholder, let accessibility, _):
|
||||||
|
placeholder.hash(into: &hasher)
|
||||||
case .threadInfo(let threadViewModel, let style, _, _, _):
|
accessibility.hash(into: &hasher)
|
||||||
threadViewModel.hash(into: &hasher)
|
|
||||||
|
case .button(let style, let title, let accessibility, _):
|
||||||
style.hash(into: &hasher)
|
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 {
|
public static func == (lhs: Accessory, rhs: Accessory) -> Bool {
|
||||||
switch (lhs, rhs) {
|
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 (
|
return (
|
||||||
lhsImage == rhsImage &&
|
lhsImage == rhsImage &&
|
||||||
lhsSize == rhsSize &&
|
lhsSize == rhsSize &&
|
||||||
lhsCustomTint == rhsCustomTint &&
|
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 (
|
return (
|
||||||
lhsSize == rhsSize &&
|
lhsSize == rhsSize &&
|
||||||
lhsCustomTint == rhsCustomTint &&
|
lhsCustomTint == rhsCustomTint &&
|
||||||
lhsShouldFill == rhsShouldFill
|
lhsShouldFill == rhsShouldFill &&
|
||||||
|
lhsAccessibility == rhsAccessibility
|
||||||
)
|
)
|
||||||
|
|
||||||
case (.toggle(let lhsDataSource), .toggle(let rhsDataSource)):
|
case (.toggle(let lhsDataSource, let lhsAccessibility), .toggle(let rhsDataSource, let rhsAccessibility)):
|
||||||
return (lhsDataSource == rhsDataSource)
|
return (
|
||||||
|
lhsDataSource == rhsDataSource &&
|
||||||
|
lhsAccessibility == rhsAccessibility
|
||||||
|
)
|
||||||
|
|
||||||
case (.dropDown(let lhsDataSource), .dropDown(let rhsDataSource)):
|
case (.dropDown(let lhsDataSource, let lhsAccessibility), .dropDown(let rhsDataSource, let rhsAccessibility)):
|
||||||
return (lhsDataSource == rhsDataSource)
|
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 (
|
return (
|
||||||
lhsSize == rhsSize &&
|
lhsSize == rhsSize &&
|
||||||
lhsIsSelected() == rhsIsSelected() &&
|
lhsIsSelected() == rhsIsSelected() &&
|
||||||
lhsStoredSelection == rhsStoredSelection
|
lhsStoredSelection == rhsStoredSelection &&
|
||||||
|
lhsAccessibility == rhsAccessibility
|
||||||
)
|
)
|
||||||
|
|
||||||
case (.highlightingBackgroundLabel(let lhsTitle), .highlightingBackgroundLabel(let rhsTitle)):
|
case (.highlightingBackgroundLabel(let lhsTitle, let lhsAccessibility), .highlightingBackgroundLabel(let rhsTitle, let rhsAccessibility)):
|
||||||
return (lhsTitle == rhsTitle)
|
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 (
|
return (
|
||||||
lhsProfileId == rhsProfileId &&
|
lhsProfileId == rhsProfileId &&
|
||||||
lhsProfile == rhsProfile
|
lhsSize == rhsSize &&
|
||||||
|
lhsThreadVariant == rhsThreadVariant &&
|
||||||
|
lhsProfile == rhsProfile &&
|
||||||
|
lhsAdditionalProfile == rhsAdditionalProfile &&
|
||||||
|
lhsCustomImageData == rhsCustomImageData &&
|
||||||
|
lhsCornerIcon == rhsCornerIcon &&
|
||||||
|
lhsAccessibility == rhsAccessibility
|
||||||
)
|
)
|
||||||
|
|
||||||
case (.customView, .customView): return false
|
case (.search(let lhsPlaceholder, let lhsAccessibility, _), .search(let rhsPlaceholder, let rhsAccessibility, _)):
|
||||||
|
|
||||||
case (.threadInfo(let lhsThreadViewModel, let lhsStyle, _, _, _), .threadInfo(let rhsThreadViewModel, let rhsStyle, _, _, _)):
|
|
||||||
return (
|
return (
|
||||||
lhsThreadViewModel == rhsThreadViewModel &&
|
lhsPlaceholder == rhsPlaceholder &&
|
||||||
lhsStyle == rhsStyle
|
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
|
default: return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -157,59 +264,121 @@ extension SessionCell.Accessory {
|
||||||
// MARK: - .icon Variants
|
// MARK: - .icon Variants
|
||||||
|
|
||||||
public static func icon(_ image: UIImage?) -> SessionCell.Accessory {
|
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 {
|
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 {
|
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 {
|
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 {
|
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
|
// MARK: - .iconAsync Variants
|
||||||
|
|
||||||
public static func iconAsync(_ setter: @escaping (UIImageView) -> Void) -> SessionCell.Accessory {
|
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 {
|
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 {
|
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 {
|
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 {
|
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 {
|
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
|
// MARK: - .radio Variants
|
||||||
|
|
||||||
public static func radio(isSelected: @escaping () -> Bool) -> SessionCell.Accessory {
|
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 {
|
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 {
|
extension SessionCell {
|
||||||
public struct Info<ID: Hashable & Differentiable>: Equatable, Hashable, Differentiable {
|
public struct Info<ID: Hashable & Differentiable>: Equatable, Hashable, Differentiable {
|
||||||
let id: ID
|
let id: ID
|
||||||
|
let position: Position
|
||||||
let leftAccessory: SessionCell.Accessory?
|
let leftAccessory: SessionCell.Accessory?
|
||||||
let title: String
|
let title: TextInfo?
|
||||||
let subtitle: String?
|
let subtitle: TextInfo?
|
||||||
let subtitleExtraViewGenerator: (() -> UIView)?
|
|
||||||
let tintColor: ThemeValue
|
|
||||||
let rightAccessory: SessionCell.Accessory?
|
let rightAccessory: SessionCell.Accessory?
|
||||||
let extraAction: SessionCell.ExtraAction?
|
let styling: StyleInfo
|
||||||
let isEnabled: Bool
|
let isEnabled: Bool
|
||||||
let shouldHaveBackground: Bool
|
let accessibility: SessionCell.Accessibility?
|
||||||
let accessibilityIdentifier: String?
|
|
||||||
let accessibilityLabel: String?
|
|
||||||
let leftAccessoryAccessibilityLabel: String?
|
|
||||||
let rightAccessoryAccessibilityLabel: String?
|
|
||||||
let confirmationInfo: ConfirmationModal.Info?
|
let confirmationInfo: ConfirmationModal.Info?
|
||||||
let onTap: ((UIView?) -> Void)?
|
let onTap: (() -> Void)?
|
||||||
|
let onTapView: ((UIView?) -> Void)?
|
||||||
|
|
||||||
var currentBoolValue: Bool {
|
var currentBoolValue: Bool {
|
||||||
return (
|
return (
|
||||||
|
@ -34,74 +30,30 @@ extension SessionCell {
|
||||||
|
|
||||||
init(
|
init(
|
||||||
id: ID,
|
id: ID,
|
||||||
|
position: Position = .individual,
|
||||||
leftAccessory: SessionCell.Accessory? = nil,
|
leftAccessory: SessionCell.Accessory? = nil,
|
||||||
title: String,
|
title: SessionCell.TextInfo? = nil,
|
||||||
subtitle: String? = nil,
|
subtitle: SessionCell.TextInfo? = nil,
|
||||||
subtitleExtraViewGenerator: (() -> UIView)? = nil,
|
|
||||||
tintColor: ThemeValue = .textPrimary,
|
|
||||||
rightAccessory: SessionCell.Accessory? = nil,
|
rightAccessory: SessionCell.Accessory? = nil,
|
||||||
extraAction: SessionCell.ExtraAction? = nil,
|
styling: StyleInfo = StyleInfo(),
|
||||||
isEnabled: Bool = true,
|
isEnabled: Bool = true,
|
||||||
shouldHaveBackground: Bool = true,
|
accessibility: SessionCell.Accessibility? = nil,
|
||||||
accessibilityIdentifier: String? = nil,
|
|
||||||
accessibilityLabel: String? = nil,
|
|
||||||
leftAccessoryAccessibilityLabel: String? = nil,
|
|
||||||
rightAccessoryAccessibilityLabel: String? = nil,
|
|
||||||
confirmationInfo: ConfirmationModal.Info? = nil,
|
confirmationInfo: ConfirmationModal.Info? = nil,
|
||||||
onTap: ((UIView?) -> Void)?
|
onTap: (() -> Void)? = nil,
|
||||||
|
onTapView: ((UIView?) -> Void)? = nil
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
|
self.position = position
|
||||||
self.leftAccessory = leftAccessory
|
self.leftAccessory = leftAccessory
|
||||||
self.title = title
|
self.title = title
|
||||||
self.subtitle = subtitle
|
self.subtitle = subtitle
|
||||||
self.subtitleExtraViewGenerator = subtitleExtraViewGenerator
|
|
||||||
self.tintColor = tintColor
|
|
||||||
self.rightAccessory = rightAccessory
|
self.rightAccessory = rightAccessory
|
||||||
self.extraAction = extraAction
|
self.styling = styling
|
||||||
self.isEnabled = isEnabled
|
self.isEnabled = isEnabled
|
||||||
self.shouldHaveBackground = shouldHaveBackground
|
self.accessibility = accessibility
|
||||||
self.accessibilityIdentifier = accessibilityIdentifier
|
|
||||||
self.accessibilityLabel = accessibilityLabel
|
|
||||||
self.leftAccessoryAccessibilityLabel = leftAccessoryAccessibilityLabel
|
|
||||||
self.rightAccessoryAccessibilityLabel = rightAccessoryAccessibilityLabel
|
|
||||||
self.confirmationInfo = confirmationInfo
|
self.confirmationInfo = confirmationInfo
|
||||||
self.onTap = onTap
|
self.onTap = onTap
|
||||||
}
|
self.onTapView = onTapView
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Conformance
|
// MARK: - Conformance
|
||||||
|
@ -110,37 +62,190 @@ extension SessionCell {
|
||||||
|
|
||||||
public func hash(into hasher: inout Hasher) {
|
public func hash(into hasher: inout Hasher) {
|
||||||
id.hash(into: &hasher)
|
id.hash(into: &hasher)
|
||||||
|
position.hash(into: &hasher)
|
||||||
leftAccessory.hash(into: &hasher)
|
leftAccessory.hash(into: &hasher)
|
||||||
title.hash(into: &hasher)
|
title.hash(into: &hasher)
|
||||||
subtitle.hash(into: &hasher)
|
subtitle.hash(into: &hasher)
|
||||||
tintColor.hash(into: &hasher)
|
|
||||||
rightAccessory.hash(into: &hasher)
|
rightAccessory.hash(into: &hasher)
|
||||||
extraAction.hash(into: &hasher)
|
styling.hash(into: &hasher)
|
||||||
isEnabled.hash(into: &hasher)
|
isEnabled.hash(into: &hasher)
|
||||||
shouldHaveBackground.hash(into: &hasher)
|
accessibility.hash(into: &hasher)
|
||||||
accessibilityIdentifier.hash(into: &hasher)
|
|
||||||
accessibilityLabel.hash(into: &hasher)
|
|
||||||
leftAccessoryAccessibilityLabel.hash(into: &hasher)
|
|
||||||
rightAccessoryAccessibilityLabel.hash(into: &hasher)
|
|
||||||
confirmationInfo.hash(into: &hasher)
|
confirmationInfo.hash(into: &hasher)
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func == (lhs: Info<ID>, rhs: Info<ID>) -> Bool {
|
public static func == (lhs: Info<ID>, rhs: Info<ID>) -> Bool {
|
||||||
return (
|
return (
|
||||||
lhs.id == rhs.id &&
|
lhs.id == rhs.id &&
|
||||||
|
lhs.position == rhs.position &&
|
||||||
lhs.leftAccessory == rhs.leftAccessory &&
|
lhs.leftAccessory == rhs.leftAccessory &&
|
||||||
lhs.title == rhs.title &&
|
lhs.title == rhs.title &&
|
||||||
lhs.subtitle == rhs.subtitle &&
|
lhs.subtitle == rhs.subtitle &&
|
||||||
lhs.tintColor == rhs.tintColor &&
|
|
||||||
lhs.rightAccessory == rhs.rightAccessory &&
|
lhs.rightAccessory == rhs.rightAccessory &&
|
||||||
lhs.extraAction == rhs.extraAction &&
|
lhs.styling == rhs.styling &&
|
||||||
lhs.isEnabled == rhs.isEnabled &&
|
lhs.isEnabled == rhs.isEnabled &&
|
||||||
lhs.shouldHaveBackground == rhs.shouldHaveBackground &&
|
lhs.accessibility == rhs.accessibility
|
||||||
lhs.accessibilityIdentifier == rhs.accessibilityIdentifier &&
|
)
|
||||||
lhs.accessibilityLabel == rhs.accessibilityLabel &&
|
}
|
||||||
lhs.leftAccessoryAccessibilityLabel == rhs.leftAccessoryAccessibilityLabel &&
|
|
||||||
lhs.rightAccessoryAccessibilityLabel == rhs.rightAccessoryAccessibilityLabel
|
// 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 Foundation
|
||||||
import DifferenceKit
|
import DifferenceKit
|
||||||
|
import SessionUIKit
|
||||||
|
|
||||||
protocol SessionTableSection: Differentiable {
|
protocol SessionTableSection: Differentiable {
|
||||||
var title: String? { get }
|
var title: String? { get }
|
||||||
|
@ -13,8 +14,36 @@ extension SessionTableSection {
|
||||||
var style: SessionTableSectionStyle { .none }
|
var style: SessionTableSectionStyle { .none }
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum SessionTableSectionStyle: Differentiable {
|
public enum SessionTableSectionStyle: Equatable, Hashable, Differentiable {
|
||||||
case none
|
case none
|
||||||
case title
|
case titleRoundedContent
|
||||||
|
case titleEdgeToEdgeContent
|
||||||
|
case titleNoBackgroundContent
|
||||||
|
case titleSeparator
|
||||||
case padding
|
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(
|
cell.update(
|
||||||
with: SessionCell.Info(
|
with: SessionCell.Info(
|
||||||
id: profile,
|
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(),
|
title: profile.displayName(),
|
||||||
rightAccessory: .radio(isSelected: { [weak self] in
|
rightAccessory: .radio(isSelected: { [weak self] in
|
||||||
self?.selectedUsers.contains(profile.id) == true
|
self?.selectedUsers.contains(profile.id) == true
|
||||||
}),
|
}),
|
||||||
accessibilityIdentifier: "Contact"
|
styling: SessionCell.StyleInfo(backgroundStyle: .edgeToEdge),
|
||||||
),
|
accessibility: SessionCell.Accessibility(identifier: "Contact")
|
||||||
style: .edgeToEdge,
|
)
|
||||||
position: Position.with(indexPath.row, count: users.count)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return cell
|
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
|
import SignalUtilitiesKit
|
||||||
|
|
||||||
extension SessionCell {
|
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
|
// 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] = [
|
private lazy var imageViewConstraints: [NSLayoutConstraint] = [
|
||||||
imageView.pin(.top, to: .top, of: self),
|
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)
|
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 imageViewWidthConstraint: NSLayoutConstraint = imageView.set(.width, to: 0)
|
||||||
private lazy var imageViewHeightConstraint: NSLayoutConstraint = imageView.set(.height, to: 0)
|
private lazy var imageViewHeightConstraint: NSLayoutConstraint = imageView.set(.height, to: 0)
|
||||||
private lazy var toggleSwitchConstraints: [NSLayoutConstraint] = [
|
private lazy var toggleSwitchConstraints: [NSLayoutConstraint] = [
|
||||||
|
@ -26,8 +36,8 @@ extension SessionCell {
|
||||||
]
|
]
|
||||||
private lazy var dropDownStackViewConstraints: [NSLayoutConstraint] = [
|
private lazy var dropDownStackViewConstraints: [NSLayoutConstraint] = [
|
||||||
dropDownStackView.pin(.top, to: .top, of: self),
|
dropDownStackView.pin(.top, to: .top, of: self),
|
||||||
dropDownStackView.pin(.leading, to: .leading, of: self),
|
dropDownStackView.pin(.leading, to: .leading, of: self, withInset: Values.smallSpacing),
|
||||||
dropDownStackView.pin(.trailing, to: .trailing, of: self),
|
dropDownStackView.pin(.trailing, to: .trailing, of: self, withInset: -Values.smallSpacing),
|
||||||
dropDownStackView.pin(.bottom, to: .bottom, of: self)
|
dropDownStackView.pin(.bottom, to: .bottom, of: self)
|
||||||
]
|
]
|
||||||
private lazy var radioViewWidthConstraint: NSLayoutConstraint = radioView.set(.width, to: 0)
|
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 radioBorderViewHeightConstraint: NSLayoutConstraint = radioBorderView.set(.height, to: 0)
|
||||||
private lazy var radioBorderViewConstraints: [NSLayoutConstraint] = [
|
private lazy var radioBorderViewConstraints: [NSLayoutConstraint] = [
|
||||||
radioBorderView.pin(.top, to: .top, of: self),
|
radioBorderView.pin(.top, to: .top, of: self),
|
||||||
radioBorderView.pin(.leading, to: .leading, of: self),
|
radioBorderView.center(.horizontal, in: self),
|
||||||
radioBorderView.pin(.trailing, to: .trailing, of: self),
|
|
||||||
radioBorderView.pin(.bottom, to: .bottom, of: self)
|
radioBorderView.pin(.bottom, to: .bottom, of: self)
|
||||||
]
|
]
|
||||||
private lazy var highlightingBackgroundLabelConstraints: [NSLayoutConstraint] = [
|
private lazy var highlightingBackgroundLabelConstraints: [NSLayoutConstraint] = [
|
||||||
highlightingBackgroundLabel.pin(.top, to: .top, of: self),
|
highlightingBackgroundLabel.pin(.top, to: .top, of: self),
|
||||||
highlightingBackgroundLabel.pin(.leading, to: .leading, of: self),
|
highlightingBackgroundLabel.pin(.leading, to: .leading, of: self, withInset: Values.smallSpacing),
|
||||||
highlightingBackgroundLabel.pin(.trailing, to: .trailing, of: self),
|
highlightingBackgroundLabel.pin(.trailing, to: .trailing, of: self, withInset: -Values.smallSpacing),
|
||||||
highlightingBackgroundLabel.pin(.bottom, to: .bottom, of: self)
|
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] = [
|
private lazy var profilePictureViewConstraints: [NSLayoutConstraint] = [
|
||||||
profilePictureView.pin(.top, to: .top, of: self),
|
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)
|
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 = {
|
private let imageView: UIImageView = {
|
||||||
let result: UIImageView = UIImageView()
|
let result: UIImageView = UIImageView()
|
||||||
|
@ -143,10 +166,45 @@ extension SessionCell {
|
||||||
private lazy var profilePictureView: ProfilePictureView = {
|
private lazy var profilePictureView: ProfilePictureView = {
|
||||||
let result: ProfilePictureView = ProfilePictureView()
|
let result: ProfilePictureView = ProfilePictureView()
|
||||||
result.translatesAutoresizingMaskIntoConstraints = false
|
result.translatesAutoresizingMaskIntoConstraints = false
|
||||||
result.size = Values.smallProfilePictureSize
|
|
||||||
result.isHidden = true
|
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
|
return result
|
||||||
}()
|
}()
|
||||||
|
@ -174,18 +232,29 @@ extension SessionCell {
|
||||||
addSubview(radioBorderView)
|
addSubview(radioBorderView)
|
||||||
addSubview(highlightingBackgroundLabel)
|
addSubview(highlightingBackgroundLabel)
|
||||||
addSubview(profilePictureView)
|
addSubview(profilePictureView)
|
||||||
|
addSubview(profileIconContainerView)
|
||||||
|
addSubview(button)
|
||||||
|
addSubview(searchBar)
|
||||||
|
|
||||||
dropDownStackView.addArrangedSubview(dropDownImageView)
|
dropDownStackView.addArrangedSubview(dropDownImageView)
|
||||||
dropDownStackView.addArrangedSubview(dropDownLabel)
|
dropDownStackView.addArrangedSubview(dropDownLabel)
|
||||||
|
|
||||||
radioBorderView.addSubview(radioView)
|
radioBorderView.addSubview(radioView)
|
||||||
radioView.center(in: radioBorderView)
|
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
|
// MARK: - Content
|
||||||
|
|
||||||
func prepareForReuse() {
|
func prepareForReuse() {
|
||||||
self.isHidden = true
|
isHidden = true
|
||||||
|
onTap = nil
|
||||||
|
searchTermChanged = nil
|
||||||
|
|
||||||
imageView.image = nil
|
imageView.image = nil
|
||||||
imageView.themeTintColor = .textPrimary
|
imageView.themeTintColor = .textPrimary
|
||||||
|
@ -207,7 +276,16 @@ extension SessionCell {
|
||||||
radioView.isHidden = true
|
radioView.isHidden = true
|
||||||
highlightingBackgroundLabel.isHidden = true
|
highlightingBackgroundLabel.isHidden = true
|
||||||
profilePictureView.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
|
imageViewWidthConstraint.isActive = false
|
||||||
imageViewHeightConstraint.isActive = false
|
imageViewHeightConstraint.isActive = false
|
||||||
imageViewConstraints.forEach { $0.isActive = false }
|
imageViewConstraints.forEach { $0.isActive = false }
|
||||||
|
@ -219,14 +297,19 @@ extension SessionCell {
|
||||||
radioBorderViewHeightConstraint.isActive = false
|
radioBorderViewHeightConstraint.isActive = false
|
||||||
radioBorderViewConstraints.forEach { $0.isActive = false }
|
radioBorderViewConstraints.forEach { $0.isActive = false }
|
||||||
highlightingBackgroundLabelConstraints.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 }
|
profilePictureViewConstraints.forEach { $0.isActive = false }
|
||||||
|
searchBarConstraints.forEach { $0.isActive = false }
|
||||||
|
buttonConstraints.forEach { $0.isActive = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
public func update(
|
public func update(
|
||||||
with accessory: Accessory?,
|
with accessory: Accessory?,
|
||||||
tintColor: ThemeValue,
|
tintColor: ThemeValue,
|
||||||
isEnabled: Bool,
|
isEnabled: Bool
|
||||||
accessibilityLabel: String?
|
|
||||||
) {
|
) {
|
||||||
guard let accessory: Accessory = accessory else { return }
|
guard let accessory: Accessory = accessory else { return }
|
||||||
|
|
||||||
|
@ -234,8 +317,9 @@ extension SessionCell {
|
||||||
self.isHidden = false
|
self.isHidden = false
|
||||||
|
|
||||||
switch accessory {
|
switch accessory {
|
||||||
case .icon(let image, let iconSize, let customTint, let shouldFill):
|
case .icon(let image, let iconSize, let customTint, let shouldFill, let accessibility):
|
||||||
imageView.accessibilityLabel = accessibilityLabel
|
imageView.accessibilityIdentifier = accessibility?.identifier
|
||||||
|
imageView.accessibilityLabel = accessibility?.label
|
||||||
imageView.image = image
|
imageView.image = image
|
||||||
imageView.themeTintColor = (customTint ?? tintColor)
|
imageView.themeTintColor = (customTint ?? tintColor)
|
||||||
imageView.contentMode = (shouldFill ? .scaleAspectFill : .scaleAspectFit)
|
imageView.contentMode = (shouldFill ? .scaleAspectFill : .scaleAspectFit)
|
||||||
|
@ -244,21 +328,30 @@ extension SessionCell {
|
||||||
switch iconSize {
|
switch iconSize {
|
||||||
case .fit:
|
case .fit:
|
||||||
imageView.sizeToFit()
|
imageView.sizeToFit()
|
||||||
|
fixedWidthConstraint.constant = (imageView.bounds.width + (shouldFill ? 0 : (Values.smallSpacing * 2)))
|
||||||
|
fixedWidthConstraint.isActive = true
|
||||||
imageViewWidthConstraint.constant = imageView.bounds.width
|
imageViewWidthConstraint.constant = imageView.bounds.width
|
||||||
imageViewHeightConstraint.constant = imageView.bounds.height
|
imageViewHeightConstraint.constant = imageView.bounds.height
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
fixedWidthConstraint.isActive = (iconSize.size <= fixedWidthConstraint.constant)
|
||||||
imageViewWidthConstraint.constant = iconSize.size
|
imageViewWidthConstraint.constant = iconSize.size
|
||||||
imageViewHeightConstraint.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
|
imageViewWidthConstraint.isActive = true
|
||||||
imageViewHeightConstraint.isActive = true
|
imageViewHeightConstraint.isActive = true
|
||||||
imageViewConstraints.forEach { $0.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)
|
setter(imageView)
|
||||||
imageView.accessibilityLabel = accessibilityLabel
|
imageView.accessibilityIdentifier = accessibility?.identifier
|
||||||
|
imageView.accessibilityLabel = accessibility?.label
|
||||||
imageView.themeTintColor = (customTint ?? tintColor)
|
imageView.themeTintColor = (customTint ?? tintColor)
|
||||||
imageView.contentMode = (shouldFill ? .scaleAspectFill : .scaleAspectFit)
|
imageView.contentMode = (shouldFill ? .scaleAspectFill : .scaleAspectFit)
|
||||||
imageView.isHidden = false
|
imageView.isHidden = false
|
||||||
|
@ -266,22 +359,33 @@ extension SessionCell {
|
||||||
switch iconSize {
|
switch iconSize {
|
||||||
case .fit:
|
case .fit:
|
||||||
imageView.sizeToFit()
|
imageView.sizeToFit()
|
||||||
|
fixedWidthConstraint.constant = (imageView.bounds.width + (shouldFill ? 0 : (Values.smallSpacing * 2)))
|
||||||
|
fixedWidthConstraint.isActive = true
|
||||||
imageViewWidthConstraint.constant = imageView.bounds.width
|
imageViewWidthConstraint.constant = imageView.bounds.width
|
||||||
imageViewHeightConstraint.constant = imageView.bounds.height
|
imageViewHeightConstraint.constant = imageView.bounds.height
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
fixedWidthConstraint.isActive = (iconSize.size <= fixedWidthConstraint.constant)
|
||||||
imageViewWidthConstraint.constant = iconSize.size
|
imageViewWidthConstraint.constant = iconSize.size
|
||||||
imageViewHeightConstraint.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
|
imageViewWidthConstraint.isActive = true
|
||||||
imageViewHeightConstraint.isActive = true
|
imageViewHeightConstraint.isActive = true
|
||||||
imageViewConstraints.forEach { $0.isActive = true }
|
imageViewConstraints.forEach { $0.isActive = true }
|
||||||
|
|
||||||
case .toggle(let dataSource):
|
case .toggle(let dataSource, let accessibility):
|
||||||
toggleSwitch.accessibilityLabel = accessibilityLabel
|
toggleSwitch.accessibilityIdentifier = accessibility?.identifier
|
||||||
|
toggleSwitch.accessibilityLabel = accessibility?.label
|
||||||
toggleSwitch.isHidden = false
|
toggleSwitch.isHidden = false
|
||||||
toggleSwitch.isEnabled = isEnabled
|
toggleSwitch.isEnabled = isEnabled
|
||||||
|
|
||||||
|
fixedWidthConstraint.isActive = true
|
||||||
toggleSwitchConstraints.forEach { $0.isActive = true }
|
toggleSwitchConstraints.forEach { $0.isActive = true }
|
||||||
|
|
||||||
let newValue: Bool = dataSource.currentBoolValue
|
let newValue: Bool = dataSource.currentBoolValue
|
||||||
|
@ -290,13 +394,15 @@ extension SessionCell {
|
||||||
toggleSwitch.setOn(newValue, animated: true)
|
toggleSwitch.setOn(newValue, animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
case .dropDown(let dataSource):
|
case .dropDown(let dataSource, let accessibility):
|
||||||
dropDownLabel.accessibilityLabel = accessibilityLabel
|
dropDownLabel.accessibilityIdentifier = accessibility?.identifier
|
||||||
|
dropDownLabel.accessibilityLabel = accessibility?.label
|
||||||
dropDownLabel.text = dataSource.currentStringValue
|
dropDownLabel.text = dataSource.currentStringValue
|
||||||
dropDownStackView.isHidden = false
|
dropDownStackView.isHidden = false
|
||||||
dropDownStackViewConstraints.forEach { $0.isActive = true }
|
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 isSelected: Bool = isSelectedRetriever()
|
||||||
let wasOldSelection: Bool = (!isSelected && storedSelection)
|
let wasOldSelection: Bool = (!isSelected && storedSelection)
|
||||||
|
|
||||||
|
@ -307,7 +413,8 @@ extension SessionCell {
|
||||||
)
|
)
|
||||||
radioBorderView.layer.cornerRadius = (size.borderSize / 2)
|
radioBorderView.layer.cornerRadius = (size.borderSize / 2)
|
||||||
|
|
||||||
radioView.accessibilityLabel = accessibilityLabel
|
radioView.accessibilityIdentifier = accessibility?.identifier
|
||||||
|
radioView.accessibilityLabel = accessibility?.label
|
||||||
radioView.alpha = (wasOldSelection ? 0.3 : 1)
|
radioView.alpha = (wasOldSelection ? 0.3 : 1)
|
||||||
radioView.isHidden = (!isSelected && !storedSelection)
|
radioView.isHidden = (!isSelected && !storedSelection)
|
||||||
radioView.themeBackgroundColor = (isSelected || wasOldSelection ?
|
radioView.themeBackgroundColor = (isSelected || wasOldSelection ?
|
||||||
|
@ -321,32 +428,89 @@ extension SessionCell {
|
||||||
radioBorderViewWidthConstraint.constant = size.borderSize
|
radioBorderViewWidthConstraint.constant = size.borderSize
|
||||||
radioBorderViewHeightConstraint.constant = size.borderSize
|
radioBorderViewHeightConstraint.constant = size.borderSize
|
||||||
|
|
||||||
|
fixedWidthConstraint.isActive = true
|
||||||
radioViewWidthConstraint.isActive = true
|
radioViewWidthConstraint.isActive = true
|
||||||
radioViewHeightConstraint.isActive = true
|
radioViewHeightConstraint.isActive = true
|
||||||
radioBorderViewWidthConstraint.isActive = true
|
radioBorderViewWidthConstraint.isActive = true
|
||||||
radioBorderViewHeightConstraint.isActive = true
|
radioBorderViewHeightConstraint.isActive = true
|
||||||
radioBorderViewConstraints.forEach { $0.isActive = true }
|
radioBorderViewConstraints.forEach { $0.isActive = true }
|
||||||
|
|
||||||
case .highlightingBackgroundLabel(let title):
|
case .highlightingBackgroundLabel(let title, let accessibility):
|
||||||
highlightingBackgroundLabel.accessibilityLabel = accessibilityLabel
|
highlightingBackgroundLabel.accessibilityIdentifier = accessibility?.identifier
|
||||||
|
highlightingBackgroundLabel.accessibilityLabel = accessibility?.label
|
||||||
highlightingBackgroundLabel.text = title
|
highlightingBackgroundLabel.text = title
|
||||||
highlightingBackgroundLabel.themeTextColor = tintColor
|
highlightingBackgroundLabel.themeTextColor = tintColor
|
||||||
highlightingBackgroundLabel.isHidden = false
|
highlightingBackgroundLabel.isHidden = false
|
||||||
highlightingBackgroundLabelConstraints.forEach { $0.isActive = true }
|
highlightingBackgroundLabelConstraints.forEach { $0.isActive = true }
|
||||||
|
minWidthConstraint.isActive = true
|
||||||
|
|
||||||
case .profile(let profileId, let profile):
|
case .profile(
|
||||||
profilePictureView.accessibilityLabel = accessibilityLabel
|
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(
|
profilePictureView.update(
|
||||||
publicKey: profileId,
|
publicKey: profileId,
|
||||||
|
threadVariant: threadVariant,
|
||||||
|
customImageData: customImageData,
|
||||||
profile: profile,
|
profile: profile,
|
||||||
threadVariant: .contact
|
additionalProfile: additionalProfile
|
||||||
)
|
)
|
||||||
profilePictureView.isHidden = false
|
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 }
|
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()
|
let generatedView: UIView = viewGenerator()
|
||||||
generatedView.accessibilityLabel = accessibilityLabel
|
|
||||||
addSubview(generatedView)
|
addSubview(generatedView)
|
||||||
|
|
||||||
generatedView.pin(.top, to: .top, of: self)
|
generatedView.pin(.top, to: .top, of: self)
|
||||||
|
@ -354,10 +518,9 @@ extension SessionCell {
|
||||||
generatedView.pin(.trailing, to: .trailing, of: self)
|
generatedView.pin(.trailing, to: .trailing, of: self)
|
||||||
generatedView.pin(.bottom, to: .bottom, of: self)
|
generatedView.pin(.bottom, to: .bottom, of: self)
|
||||||
|
|
||||||
self.customView?.removeFromSuperview() // Just in case
|
customView?.removeFromSuperview() // Just in case
|
||||||
self.customView = generatedView
|
customView = generatedView
|
||||||
|
minWidthConstraint.isActive = true
|
||||||
case .threadInfo: break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -370,6 +533,27 @@ extension SessionCell {
|
||||||
func setSelected(_ selected: Bool, animated: Bool) {
|
func setSelected(_ selected: Bool, animated: Bool) {
|
||||||
highlightingBackgroundLabel.setSelected(selected, animated: animated)
|
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.
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import Combine
|
||||||
import GRDB
|
import GRDB
|
||||||
import DifferenceKit
|
import DifferenceKit
|
||||||
import SessionUIKit
|
import SessionUIKit
|
||||||
|
@ -9,17 +10,13 @@ import SessionUtilitiesKit
|
||||||
public class SessionCell: UITableViewCell {
|
public class SessionCell: UITableViewCell {
|
||||||
public static let cornerRadius: CGFloat = 17
|
public static let cornerRadius: CGFloat = 17
|
||||||
|
|
||||||
public enum Style {
|
private var isEditingTitle = false
|
||||||
case rounded
|
public private(set) var interactionMode: SessionCell.TextInfo.Interaction = .none
|
||||||
case roundedEdgeToEdge
|
private var shouldHighlightTitle: Bool = true
|
||||||
case edgeToEdge
|
private var originalInputValue: String?
|
||||||
}
|
private var titleExtraView: UIView?
|
||||||
|
|
||||||
/// 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 subtitleExtraView: UIView?
|
private var subtitleExtraView: UIView?
|
||||||
private var onExtraActionTap: (() -> Void)?
|
var disposables: Set<AnyCancellable> = Set()
|
||||||
|
|
||||||
// MARK: - UI
|
// MARK: - UI
|
||||||
|
|
||||||
|
@ -29,8 +26,18 @@ public class SessionCell: UITableViewCell {
|
||||||
private var topSeparatorRightConstraint: NSLayoutConstraint = NSLayoutConstraint()
|
private var topSeparatorRightConstraint: NSLayoutConstraint = NSLayoutConstraint()
|
||||||
private var botSeparatorLeftConstraint: NSLayoutConstraint = NSLayoutConstraint()
|
private var botSeparatorLeftConstraint: NSLayoutConstraint = NSLayoutConstraint()
|
||||||
private var botSeparatorRightConstraint: 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 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 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 = {
|
private let cellBackgroundView: UIView = {
|
||||||
let result: UIView = UIView()
|
let result: UIView = UIView()
|
||||||
|
@ -65,7 +72,6 @@ public class SessionCell: UITableViewCell {
|
||||||
result.distribution = .fill
|
result.distribution = .fill
|
||||||
result.alignment = .center
|
result.alignment = .center
|
||||||
result.spacing = Values.mediumSpacing
|
result.spacing = Values.mediumSpacing
|
||||||
result.isLayoutMarginsRelativeArrangement = true
|
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
@ -89,10 +95,10 @@ public class SessionCell: UITableViewCell {
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private let titleLabel: UILabel = {
|
private let titleLabel: SRCopyableLabel = {
|
||||||
let result: UILabel = UILabel()
|
let result: SRCopyableLabel = SRCopyableLabel()
|
||||||
result.translatesAutoresizingMaskIntoConstraints = false
|
result.translatesAutoresizingMaskIntoConstraints = false
|
||||||
result.font = .boldSystemFont(ofSize: 15)
|
result.isUserInteractionEnabled = false
|
||||||
result.themeTextColor = .textPrimary
|
result.themeTextColor = .textPrimary
|
||||||
result.numberOfLines = 0
|
result.numberOfLines = 0
|
||||||
result.setCompressionResistanceHorizontalLow()
|
result.setCompressionResistanceHorizontalLow()
|
||||||
|
@ -101,10 +107,21 @@ public class SessionCell: UITableViewCell {
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private let subtitleLabel: UILabel = {
|
fileprivate let titleTextField: UITextField = {
|
||||||
let result: UILabel = UILabel()
|
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.translatesAutoresizingMaskIntoConstraints = false
|
||||||
result.font = .systemFont(ofSize: 13)
|
result.isUserInteractionEnabled = false
|
||||||
result.themeTextColor = .textPrimary
|
result.themeTextColor = .textPrimary
|
||||||
result.numberOfLines = 0
|
result.numberOfLines = 0
|
||||||
result.isHidden = true
|
result.isHidden = true
|
||||||
|
@ -114,33 +131,6 @@ public class SessionCell: UITableViewCell {
|
||||||
return result
|
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 = {
|
public let rightAccessoryView: AccessoryView = {
|
||||||
let result: AccessoryView = AccessoryView()
|
let result: AccessoryView = AccessoryView()
|
||||||
result.isHidden = true
|
result.isHidden = true
|
||||||
|
@ -186,8 +176,8 @@ public class SessionCell: UITableViewCell {
|
||||||
|
|
||||||
titleStackView.addArrangedSubview(titleLabel)
|
titleStackView.addArrangedSubview(titleLabel)
|
||||||
titleStackView.addArrangedSubview(subtitleLabel)
|
titleStackView.addArrangedSubview(subtitleLabel)
|
||||||
titleStackView.addArrangedSubview(extraActionTopSpacingView)
|
|
||||||
titleStackView.addArrangedSubview(extraActionButton)
|
cellBackgroundView.addSubview(titleTextField)
|
||||||
|
|
||||||
setupLayout()
|
setupLayout()
|
||||||
}
|
}
|
||||||
|
@ -204,7 +194,10 @@ public class SessionCell: UITableViewCell {
|
||||||
topSeparatorLeftConstraint = topSeparator.pin(.left, to: .left, of: cellBackgroundView)
|
topSeparatorLeftConstraint = topSeparator.pin(.left, to: .left, of: cellBackgroundView)
|
||||||
topSeparatorRightConstraint = topSeparator.pin(.right, to: .right, 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)
|
botSeparatorLeftConstraint = botSeparator.pin(.left, to: .left, of: cellBackgroundView)
|
||||||
botSeparatorRightConstraint = botSeparator.pin(.right, to: .right, 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
|
// Need to force the contentStackView to layout if needed as it might not have updated it's
|
||||||
// sizing yet
|
// sizing yet
|
||||||
self.contentStackView.layoutIfNeeded()
|
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
|
// Position the 'targetView' at the end of the last line of text
|
||||||
if
|
let layoutManager: NSLayoutManager = NSLayoutManager()
|
||||||
let subtitleExtraView: UIView = self.subtitleExtraView,
|
let textStorage = NSTextStorage(
|
||||||
let subtitle: String = subtitleLabel.text,
|
attributedString: NSAttributedString(
|
||||||
let font: UIFont = subtitleLabel.font
|
string: content,
|
||||||
{
|
attributes: [ .font: font ]
|
||||||
let layoutManager: NSLayoutManager = NSLayoutManager()
|
|
||||||
let textStorage = NSTextStorage(
|
|
||||||
attributedString: NSAttributedString(
|
|
||||||
string: subtitle,
|
|
||||||
attributes: [ .font: font ]
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
textStorage.addLayoutManager(layoutManager)
|
)
|
||||||
|
textStorage.addLayoutManager(layoutManager)
|
||||||
let textContainer: NSTextContainer = NSTextContainer(
|
|
||||||
size: CGSize(
|
let textContainer: NSTextContainer = NSTextContainer(
|
||||||
width: subtitleLabel.bounds.size.width,
|
size: CGSize(
|
||||||
height: 999
|
width: label.bounds.size.width,
|
||||||
)
|
height: 999
|
||||||
)
|
)
|
||||||
textContainer.lineFragmentPadding = 0
|
)
|
||||||
layoutManager.addTextContainer(textContainer)
|
textContainer.lineFragmentPadding = 0
|
||||||
|
layoutManager.addTextContainer(textContainer)
|
||||||
var glyphRange: NSRange = NSRange()
|
|
||||||
layoutManager.characterRange(
|
var glyphRange: NSRange = NSRange()
|
||||||
forGlyphRange: NSRange(location: subtitle.glyphCount - 1, length: 1),
|
layoutManager.characterRange(
|
||||||
actualGlyphRange: &glyphRange
|
forGlyphRange: NSRange(location: content.glyphCount - 1, length: 1),
|
||||||
)
|
actualGlyphRange: &glyphRange
|
||||||
let lastGlyphRect: CGRect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer)
|
)
|
||||||
|
let lastGlyphRect: CGRect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer)
|
||||||
// Remove and re-add the 'subtitleExtraView' to clear any old constraints
|
|
||||||
subtitleExtraView.removeFromSuperview()
|
// Remove and re-add the 'subtitleExtraView' to clear any old constraints
|
||||||
contentView.addSubview(subtitleExtraView)
|
targetView.removeFromSuperview()
|
||||||
|
contentView.addSubview(targetView)
|
||||||
subtitleExtraView.pin(
|
|
||||||
.top,
|
targetView.pin(
|
||||||
to: .top,
|
.top,
|
||||||
of: subtitleLabel,
|
to: .top,
|
||||||
withInset: (lastGlyphRect.minY + ((lastGlyphRect.height / 2) - (subtitleExtraView.bounds.height / 2)))
|
of: label,
|
||||||
)
|
withInset: (lastGlyphRect.minY + ((lastGlyphRect.height / 2) - (targetView.bounds.height / 2)))
|
||||||
subtitleExtraView.pin(
|
)
|
||||||
.leading,
|
targetView.pin(
|
||||||
to: .leading,
|
.leading,
|
||||||
of: subtitleLabel,
|
to: .leading,
|
||||||
withInset: lastGlyphRect.maxX
|
of: label,
|
||||||
)
|
withInset: lastGlyphRect.maxX
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Content
|
// MARK: - Content
|
||||||
|
@ -273,108 +270,185 @@ public class SessionCell: UITableViewCell {
|
||||||
public override func prepareForReuse() {
|
public override func prepareForReuse() {
|
||||||
super.prepareForReuse()
|
super.prepareForReuse()
|
||||||
|
|
||||||
self.instanceView = UIView()
|
isEditingTitle = false
|
||||||
self.position = nil
|
interactionMode = .none
|
||||||
self.onExtraActionTap = nil
|
shouldHighlightTitle = true
|
||||||
self.accessibilityIdentifier = nil
|
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.prepareForReuse()
|
||||||
|
leftAccessoryView.alpha = 1
|
||||||
leftAccessoryFillConstraint.isActive = false
|
leftAccessoryFillConstraint.isActive = false
|
||||||
titleLabel.text = ""
|
titleLabel.text = ""
|
||||||
|
titleLabel.textAlignment = .left
|
||||||
titleLabel.themeTextColor = .textPrimary
|
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.text = ""
|
||||||
subtitleLabel.themeTextColor = .textPrimary
|
subtitleLabel.themeTextColor = .textPrimary
|
||||||
rightAccessoryView.prepareForReuse()
|
rightAccessoryView.prepareForReuse()
|
||||||
|
rightAccessoryView.alpha = 1
|
||||||
rightAccessoryFillConstraint.isActive = false
|
rightAccessoryFillConstraint.isActive = false
|
||||||
|
accessoryWidthMatchConstraint.isActive = false
|
||||||
|
|
||||||
topSeparator.isHidden = true
|
topSeparator.isHidden = true
|
||||||
subtitleLabel.isHidden = true
|
subtitleLabel.isHidden = true
|
||||||
extraActionTopSpacingView.isHidden = true
|
|
||||||
extraActionButton.setTitle("", for: .normal)
|
|
||||||
extraActionButton.isHidden = true
|
|
||||||
botSeparator.isHidden = true
|
botSeparator.isHidden = true
|
||||||
|
|
||||||
subtitleExtraView?.removeFromSuperview()
|
|
||||||
subtitleExtraView = nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public func update<ID: Hashable & Differentiable>(
|
public func update<ID: Hashable & Differentiable>(with info: Info<ID>) {
|
||||||
with info: Info<ID>,
|
interactionMode = (info.title?.interaction ?? .none)
|
||||||
style: Style,
|
shouldHighlightTitle = (info.title?.interaction != .copy)
|
||||||
position: Position
|
titleExtraView = info.title?.extraViewGenerator?()
|
||||||
) {
|
subtitleExtraView = info.subtitle?.extraViewGenerator?()
|
||||||
self.instanceView = UIView()
|
accessibilityIdentifier = info.accessibility?.identifier
|
||||||
self.position = position
|
accessibilityLabel = info.accessibility?.label
|
||||||
self.subtitleExtraView = info.subtitleExtraViewGenerator?()
|
originalInputValue = info.title?.text
|
||||||
self.onExtraActionTap = info.extraAction?.onTap
|
|
||||||
self.accessibilityIdentifier = info.accessibilityIdentifier
|
|
||||||
self.accessibilityLabel = info.accessibilityLabel
|
|
||||||
self.isAccessibilityElement = true
|
|
||||||
|
|
||||||
|
// Convenience Flags
|
||||||
let leftFitToEdge: Bool = (info.leftAccessory?.shouldFitToEdge == true)
|
let leftFitToEdge: Bool = (info.leftAccessory?.shouldFitToEdge == true)
|
||||||
let rightFitToEdge: Bool = (!leftFitToEdge && info.rightAccessory?.shouldFitToEdge == true)
|
let rightFitToEdge: Bool = (!leftFitToEdge && info.rightAccessory?.shouldFitToEdge == true)
|
||||||
leftAccessoryFillConstraint.isActive = leftFitToEdge
|
|
||||||
|
// Content
|
||||||
|
contentStackView.spacing = (info.styling.customPadding?.interItem ?? Values.mediumSpacing)
|
||||||
leftAccessoryView.update(
|
leftAccessoryView.update(
|
||||||
with: info.leftAccessory,
|
with: info.leftAccessory,
|
||||||
tintColor: info.tintColor,
|
tintColor: info.styling.tintColor,
|
||||||
isEnabled: info.isEnabled,
|
isEnabled: info.isEnabled
|
||||||
accessibilityLabel: info.leftAccessoryAccessibilityLabel
|
|
||||||
)
|
)
|
||||||
|
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(
|
rightAccessoryView.update(
|
||||||
with: info.rightAccessory,
|
with: info.rightAccessory,
|
||||||
tintColor: info.tintColor,
|
tintColor: info.styling.tintColor,
|
||||||
isEnabled: info.isEnabled,
|
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)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
titleLabel.text = info.title
|
contentStackViewLeadingConstraint.isActive = (info.styling.alignment == .leading)
|
||||||
titleLabel.themeTextColor = info.tintColor
|
contentStackViewTrailingConstraint.isActive = (info.styling.alignment == .leading)
|
||||||
subtitleLabel.text = info.subtitle
|
contentStackViewHorizontalCenterConstraint.constant = ((info.styling.customPadding?.leading ?? 0) + (info.styling.customPadding?.trailing ?? 0))
|
||||||
subtitleLabel.themeTextColor = info.tintColor
|
contentStackViewHorizontalCenterConstraint.isActive = (info.styling.alignment == .centerHugging)
|
||||||
subtitleLabel.isHidden = (info.subtitle == nil)
|
leftAccessoryFillConstraint.isActive = leftFitToEdge
|
||||||
extraActionTopSpacingView.isHidden = (info.extraAction == nil)
|
rightAccessoryFillConstraint.isActive = rightFitToEdge
|
||||||
extraActionButton.setTitle(info.extraAction?.title, for: .normal)
|
accessoryWidthMatchConstraint.isActive = {
|
||||||
extraActionButton.isHidden = (info.extraAction == nil)
|
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
|
// Styling and positioning
|
||||||
let defaultEdgePadding: CGFloat
|
let defaultEdgePadding: CGFloat
|
||||||
cellBackgroundView.themeBackgroundColor = (info.shouldHaveBackground ?
|
|
||||||
.settings_tabBackground :
|
|
||||||
nil
|
|
||||||
)
|
|
||||||
cellSelectedBackgroundView.isHidden = (!info.isEnabled || !info.shouldHaveBackground)
|
|
||||||
|
|
||||||
switch style {
|
switch info.styling.backgroundStyle {
|
||||||
case .rounded:
|
case .rounded:
|
||||||
|
cellBackgroundView.themeBackgroundColor = .settings_tabBackground
|
||||||
|
cellSelectedBackgroundView.isHidden = !info.isEnabled
|
||||||
|
|
||||||
defaultEdgePadding = Values.mediumSpacing
|
defaultEdgePadding = Values.mediumSpacing
|
||||||
backgroundLeftConstraint.constant = Values.largeSpacing
|
backgroundLeftConstraint.constant = Values.largeSpacing
|
||||||
backgroundRightConstraint.constant = -Values.largeSpacing
|
backgroundRightConstraint.constant = -Values.largeSpacing
|
||||||
cellBackgroundView.layer.cornerRadius = SessionCell.cornerRadius
|
cellBackgroundView.layer.cornerRadius = SessionCell.cornerRadius
|
||||||
|
|
||||||
case .edgeToEdge:
|
case .edgeToEdge:
|
||||||
|
cellBackgroundView.themeBackgroundColor = .settings_tabBackground
|
||||||
|
cellSelectedBackgroundView.isHidden = !info.isEnabled
|
||||||
|
|
||||||
defaultEdgePadding = 0
|
defaultEdgePadding = 0
|
||||||
backgroundLeftConstraint.constant = 0
|
backgroundLeftConstraint.constant = 0
|
||||||
backgroundRightConstraint.constant = 0
|
backgroundRightConstraint.constant = 0
|
||||||
cellBackgroundView.layer.cornerRadius = 0
|
cellBackgroundView.layer.cornerRadius = 0
|
||||||
|
|
||||||
case .roundedEdgeToEdge:
|
case .noBackground:
|
||||||
defaultEdgePadding = Values.mediumSpacing
|
defaultEdgePadding = Values.mediumSpacing
|
||||||
backgroundLeftConstraint.constant = 0
|
backgroundLeftConstraint.constant = Values.largeSpacing
|
||||||
backgroundRightConstraint.constant = 0
|
backgroundRightConstraint.constant = -Values.largeSpacing
|
||||||
cellBackgroundView.layer.cornerRadius = SessionCell.cornerRadius
|
cellBackgroundView.themeBackgroundColor = nil
|
||||||
|
cellBackgroundView.layer.cornerRadius = 0
|
||||||
|
cellSelectedBackgroundView.isHidden = true
|
||||||
}
|
}
|
||||||
|
|
||||||
let fittedEdgePadding: CGFloat = {
|
let fittedEdgePadding: CGFloat = {
|
||||||
func targetSize(accessory: Accessory?) -> CGFloat {
|
func targetSize(accessory: Accessory?) -> CGFloat {
|
||||||
switch accessory {
|
switch accessory {
|
||||||
case .icon(_, let iconSize, _, _), .iconAsync(let iconSize, _, _, _):
|
case .icon(_, let iconSize, _, _, _), .iconAsync(let iconSize, _, _, _, _):
|
||||||
return iconSize.size
|
return iconSize.size
|
||||||
|
|
||||||
default: return defaultEdgePadding
|
default: return defaultEdgePadding
|
||||||
|
@ -394,43 +468,103 @@ public class SessionCell: UITableViewCell {
|
||||||
botSeparatorLeftConstraint.constant = (leftFitToEdge ? fittedEdgePadding : defaultEdgePadding)
|
botSeparatorLeftConstraint.constant = (leftFitToEdge ? fittedEdgePadding : defaultEdgePadding)
|
||||||
botSeparatorRightConstraint.constant = (rightFitToEdge ? -fittedEdgePadding : -defaultEdgePadding)
|
botSeparatorRightConstraint.constant = (rightFitToEdge ? -fittedEdgePadding : -defaultEdgePadding)
|
||||||
|
|
||||||
switch position {
|
switch info.position {
|
||||||
case .top:
|
case .top:
|
||||||
cellBackgroundView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
|
cellBackgroundView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
|
||||||
topSeparator.isHidden = (style != .edgeToEdge)
|
topSeparator.isHidden = (
|
||||||
botSeparator.isHidden = false
|
!info.styling.allowedSeparators.contains(.top) ||
|
||||||
|
info.styling.backgroundStyle != .edgeToEdge
|
||||||
|
)
|
||||||
|
botSeparator.isHidden = (
|
||||||
|
!info.styling.allowedSeparators.contains(.bottom) ||
|
||||||
|
info.styling.backgroundStyle == .noBackground
|
||||||
|
)
|
||||||
|
|
||||||
case .middle:
|
case .middle:
|
||||||
cellBackgroundView.layer.maskedCorners = []
|
cellBackgroundView.layer.maskedCorners = []
|
||||||
topSeparator.isHidden = true
|
topSeparator.isHidden = true
|
||||||
botSeparator.isHidden = false
|
botSeparator.isHidden = (
|
||||||
|
!info.styling.allowedSeparators.contains(.bottom) ||
|
||||||
|
info.styling.backgroundStyle == .noBackground
|
||||||
|
)
|
||||||
|
|
||||||
case .bottom:
|
case .bottom:
|
||||||
cellBackgroundView.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner]
|
cellBackgroundView.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner]
|
||||||
topSeparator.isHidden = false
|
topSeparator.isHidden = true
|
||||||
botSeparator.isHidden = (style != .edgeToEdge)
|
botSeparator.isHidden = (
|
||||||
|
!info.styling.allowedSeparators.contains(.bottom) ||
|
||||||
|
info.styling.backgroundStyle != .edgeToEdge
|
||||||
|
)
|
||||||
|
|
||||||
case .individual:
|
case .individual:
|
||||||
cellBackgroundView.layer.maskedCorners = [
|
cellBackgroundView.layer.maskedCorners = [
|
||||||
.layerMinXMinYCorner, .layerMaxXMinYCorner,
|
.layerMinXMinYCorner, .layerMaxXMinYCorner,
|
||||||
.layerMinXMaxYCorner, .layerMaxXMaxYCorner
|
.layerMinXMaxYCorner, .layerMaxXMaxYCorner
|
||||||
]
|
]
|
||||||
topSeparator.isHidden = (style != .edgeToEdge)
|
topSeparator.isHidden = (
|
||||||
botSeparator.isHidden = (style != .edgeToEdge)
|
!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
|
// MARK: - Interaction
|
||||||
|
|
||||||
public override func setHighlighted(_ highlighted: Bool, animated: Bool) {
|
public override func setHighlighted(_ highlighted: Bool, animated: Bool) {
|
||||||
super.setHighlighted(highlighted, animated: animated)
|
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
|
// If the 'cellSelectedBackgroundView' is hidden then there is no background so we
|
||||||
// should update the titleLabel to indicate the highlighted state
|
// should update the titleLabel to indicate the highlighted state
|
||||||
if cellSelectedBackgroundView.isHidden {
|
if cellSelectedBackgroundView.isHidden && shouldHighlightTitle {
|
||||||
titleLabel.alpha = (highlighted ? 0.8 : 1)
|
// 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)
|
cellSelectedBackgroundView.alpha = (highlighted ? 1 : 0)
|
||||||
|
@ -440,12 +574,18 @@ public class SessionCell: UITableViewCell {
|
||||||
|
|
||||||
public override func setSelected(_ selected: Bool, animated: Bool) {
|
public override func setSelected(_ selected: Bool, animated: Bool) {
|
||||||
super.setSelected(selected, animated: animated)
|
super.setSelected(selected, animated: animated)
|
||||||
|
|
||||||
leftAccessoryView.setSelected(selected, animated: animated)
|
leftAccessoryView.setSelected(selected, animated: animated)
|
||||||
rightAccessoryView.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
|
import SessionUIKit
|
||||||
|
|
||||||
class SessionHeaderView: UITableViewHeaderFooterView {
|
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
|
// MARK: - UI
|
||||||
|
|
||||||
private let stackView: UIStackView = {
|
private lazy var titleLabelConstraints: [NSLayoutConstraint] = [
|
||||||
let result: UIStackView = UIStackView()
|
titleLabel.pin(.top, to: .top, of: self, withInset: Values.mediumSpacing),
|
||||||
result.translatesAutoresizingMaskIntoConstraints = false
|
titleLabel.pin(.bottom, to: .bottom, of: self, withInset: -Values.mediumSpacing)
|
||||||
result.axis = .vertical
|
]
|
||||||
result.distribution = .fill
|
private lazy var titleLabelLeadingConstraint: NSLayoutConstraint = titleLabel.pin(.leading, to: .leading, of: self)
|
||||||
result.alignment = .fill
|
private lazy var titleLabelTrailingConstraint: NSLayoutConstraint = titleLabel.pin(.trailing, to: .trailing, of: self)
|
||||||
result.isLayoutMarginsRelativeArrangement = true
|
private lazy var titleSeparatorLeadingConstraint: NSLayoutConstraint = titleSeparator.pin(.leading, to: .leading, of: self)
|
||||||
|
private lazy var titleSeparatorTrailingConstraint: NSLayoutConstraint = titleSeparator.pin(.trailing, to: .trailing, of: self)
|
||||||
return result
|
|
||||||
}()
|
|
||||||
|
|
||||||
private let titleLabel: UILabel = {
|
private let titleLabel: UILabel = {
|
||||||
let result: UILabel = UILabel()
|
let result: UILabel = UILabel()
|
||||||
result.translatesAutoresizingMaskIntoConstraints = false
|
result.translatesAutoresizingMaskIntoConstraints = false
|
||||||
result.font = .systemFont(ofSize: Values.mediumFontSize)
|
result.font = .systemFont(ofSize: Values.mediumFontSize)
|
||||||
result.themeTextColor = .textSecondary
|
result.themeTextColor = .textSecondary
|
||||||
|
result.isHidden = true
|
||||||
|
|
||||||
return result
|
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
|
// MARK: - Initialization
|
||||||
|
|
||||||
|
@ -41,10 +51,9 @@ class SessionHeaderView: UITableViewHeaderFooterView {
|
||||||
self.backgroundView = UIView()
|
self.backgroundView = UIView()
|
||||||
self.backgroundView?.themeBackgroundColor = .backgroundPrimary
|
self.backgroundView?.themeBackgroundColor = .backgroundPrimary
|
||||||
|
|
||||||
addSubview(stackView)
|
addSubview(titleLabel)
|
||||||
addSubview(separator)
|
addSubview(titleSeparator)
|
||||||
|
addSubview(loadingIndicator)
|
||||||
stackView.addArrangedSubview(titleLabel)
|
|
||||||
|
|
||||||
setupLayout()
|
setupLayout()
|
||||||
}
|
}
|
||||||
|
@ -54,42 +63,59 @@ class SessionHeaderView: UITableViewHeaderFooterView {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupLayout() {
|
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)
|
titleSeparator.center(.vertical, in: self)
|
||||||
separator.pin(.right, to: .right, of: self)
|
loadingIndicator.center(in: self)
|
||||||
separator.pin(.bottom, to: .bottom, of: self)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Content
|
// 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(
|
public func update(
|
||||||
style: SessionCell.Style = .rounded,
|
|
||||||
title: String?,
|
title: String?,
|
||||||
hasSeparator: Bool
|
style: SessionTableSectionStyle = .titleRoundedContent
|
||||||
) {
|
) {
|
||||||
let titleIsEmpty: Bool = (title ?? "").isEmpty
|
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
|
switch style {
|
||||||
titleLabel.isHidden = titleIsEmpty
|
case .titleRoundedContent, .titleEdgeToEdgeContent, .titleNoBackgroundContent:
|
||||||
stackView.layoutMargins = UIEdgeInsets(
|
titleLabel.text = title
|
||||||
top: (titleIsEmpty ? Values.verySmallSpacing : Values.mediumSpacing),
|
titleLabel.isHidden = titleIsEmpty
|
||||||
left: edgePadding,
|
titleLabelLeadingConstraint.constant = style.edgePadding
|
||||||
bottom: (titleIsEmpty ? Values.verySmallSpacing : Values.mediumSpacing),
|
titleLabelTrailingConstraint.constant = -style.edgePadding
|
||||||
right: edgePadding
|
titleLabelLeadingConstraint.isActive = !titleIsEmpty
|
||||||
)
|
titleLabelTrailingConstraint.isActive = !titleIsEmpty
|
||||||
emptyHeightConstraint.isActive = titleIsEmpty
|
titleLabelConstraints.forEach { $0.isActive = true }
|
||||||
filledHeightConstraint.isActive = !titleIsEmpty
|
|
||||||
separator.isHidden = (style == .rounded || !hasSeparator)
|
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()
|
self.layoutIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
|
@ -110,22 +110,14 @@ public final class BackgroundPoller {
|
||||||
.eraseToAnyPublisher()
|
.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
|
||||||
let promises: [Promise<Void>] = jobsToRun.map { job -> Promise<Void> in
|
return ClosedGroupPoller.poll(
|
||||||
let (promise, seal) = Promise<Void>.pending()
|
namespaces: ClosedGroupPoller.namespaces,
|
||||||
|
from: snode,
|
||||||
// Note: In the background we just want jobs to fail silently
|
for: groupPublicKey,
|
||||||
MessageReceiveJob.run(
|
on: DispatchQueue.main,
|
||||||
job,
|
calledFromBackgroundPoller: true,
|
||||||
queue: DispatchQueue.main,
|
isBackgroundPollValid: { BackgroundPoller.isValid }
|
||||||
success: { _, _ in seal.fulfill(()) },
|
)
|
||||||
failure: { _, _, _ in seal.fulfill(()) },
|
|
||||||
deferred: { _ in seal.fulfill(()) }
|
|
||||||
)
|
|
||||||
|
|
||||||
return promise
|
|
||||||
}
|
|
||||||
|
|
||||||
return when(fulfilled: promises)
|
|
||||||
}
|
}
|
||||||
.eraseToAnyPublisher()
|
.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
|
// MARK: - Convenience
|
||||||
|
|
||||||
public extension ClosedGroup {
|
public extension ClosedGroup {
|
||||||
func asProfile() -> Profile {
|
|
||||||
return Profile(
|
|
||||||
id: threadId,
|
|
||||||
name: name,
|
|
||||||
profilePictureUrl: groupImageUrl,
|
|
||||||
profilePictureFileName: groupImageFileName,
|
|
||||||
profileEncryptionKey: groupImageEncryptionKey
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func removeKeysAndUnsubscribe(
|
static func removeKeysAndUnsubscribe(
|
||||||
_ db: Database? = nil,
|
_ db: Database? = nil,
|
||||||
threadId: String,
|
threadId: String,
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import SessionUtilitiesKit
|
||||||
|
|
||||||
extension FileServerAPI {
|
extension FileServerAPI {
|
||||||
public enum Endpoint: EndpointType {
|
public enum Endpoint: EndpointType {
|
||||||
|
@ -8,7 +9,7 @@ extension FileServerAPI {
|
||||||
case fileIndividual(fileId: String)
|
case fileIndividual(fileId: String)
|
||||||
case sessionVersion
|
case sessionVersion
|
||||||
|
|
||||||
var path: String {
|
public var path: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .file: return "file"
|
case .file: return "file"
|
||||||
case .fileIndividual(let fileId): return "file/\(fileId)"
|
case .fileIndividual(let fileId): return "file/\(fileId)"
|
||||||
|
|
|
@ -338,8 +338,6 @@ public final class ClosedGroupControlMessage: ControlMessage {
|
||||||
let contentProto = SNProtoContent.builder()
|
let contentProto = SNProtoContent.builder()
|
||||||
let dataMessageProto = SNProtoDataMessage.builder()
|
let dataMessageProto = SNProtoDataMessage.builder()
|
||||||
dataMessageProto.setClosedGroupControlMessage(try closedGroupControlMessage.build())
|
dataMessageProto.setClosedGroupControlMessage(try closedGroupControlMessage.build())
|
||||||
// Group context
|
|
||||||
try setGroupContextIfNeeded(db, on: dataMessageProto)
|
|
||||||
contentProto.setDataMessage(try dataMessageProto.build())
|
contentProto.setDataMessage(try dataMessageProto.build())
|
||||||
return try contentProto.build()
|
return try contentProto.build()
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
@ -77,13 +77,6 @@ public final class ExpirationTimerUpdate: ControlMessage {
|
||||||
dataMessageProto.setFlags(UInt32(SNProtoDataMessage.SNProtoDataMessageFlags.expirationTimerUpdate.rawValue))
|
dataMessageProto.setFlags(UInt32(SNProtoDataMessage.SNProtoDataMessageFlags.expirationTimerUpdate.rawValue))
|
||||||
dataMessageProto.setExpireTimer(duration)
|
dataMessageProto.setExpireTimer(duration)
|
||||||
if let syncTarget = syncTarget { dataMessageProto.setSyncTarget(syncTarget) }
|
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()
|
let contentProto = SNProtoContent.builder()
|
||||||
do {
|
do {
|
||||||
contentProto.setDataMessage(try dataMessageProto.build())
|
contentProto.setDataMessage(try dataMessageProto.build())
|
||||||
|
|
|
@ -24,6 +24,13 @@ public extension Message {
|
||||||
)
|
)
|
||||||
case openGroupInbox(server: String, openGroupPublicKey: String, blindedPublicKey: String)
|
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(
|
public static func from(
|
||||||
_ db: Database,
|
_ db: Database,
|
||||||
thread: SessionThread,
|
thread: SessionThread,
|
||||||
|
|
|
@ -63,18 +63,6 @@ public class Message: Codable {
|
||||||
public func toProto(_ db: Database) -> SNProtoContent? {
|
public func toProto(_ db: Database) -> SNProtoContent? {
|
||||||
preconditionFailure("toProto(_:) is abstract and must be overridden.")
|
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
|
// MARK: - Message Parsing/Processing
|
||||||
|
|
|
@ -158,7 +158,7 @@ public final class VisibleMessage: Message {
|
||||||
|
|
||||||
// Attachments
|
// 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() }
|
let attachmentProtos = (attachments ?? []).compactMap { $0.buildProto() }
|
||||||
dataMessage.setAttachments(attachmentProtos)
|
dataMessage.setAttachments(attachmentProtos)
|
||||||
|
|
||||||
|
@ -175,14 +175,6 @@ public final class VisibleMessage: Message {
|
||||||
dataMessage.setReaction(reactionProto)
|
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
|
// Sync target
|
||||||
if let syncTarget = syncTarget {
|
if let syncTarget = syncTarget {
|
||||||
dataMessage.setSyncTarget(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 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 {
|
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 {
|
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 {
|
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
|
// Verify the signature based on the SessionId.Prefix type
|
||||||
|
@ -80,18 +80,18 @@ extension OpenGroupAPI.Message {
|
||||||
case .blinded:
|
case .blinded:
|
||||||
guard dependencies.sign.verify(message: data.bytes, publicKey: publicKey.bytes, signature: signature.bytes) else {
|
guard dependencies.sign.verify(message: data.bytes, publicKey: publicKey.bytes, signature: signature.bytes) else {
|
||||||
SNLog("Ignoring message with invalid signature.")
|
SNLog("Ignoring message with invalid signature.")
|
||||||
throw HTTP.Error.parsingFailed
|
throw HTTPError.parsingFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
case .standard, .unblinded:
|
case .standard, .unblinded:
|
||||||
guard (try? dependencies.ed25519.verifySignature(signature, publicKey: publicKey, data: data)) == true else {
|
guard (try? dependencies.ed25519.verifySignature(signature, publicKey: publicKey, data: data)) == true else {
|
||||||
SNLog("Ignoring message with invalid signature.")
|
SNLog("Ignoring message with invalid signature.")
|
||||||
throw HTTP.Error.parsingFailed
|
throw HTTPError.parsingFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
case .none:
|
case .none:
|
||||||
SNLog("Ignoring message with invalid sender.")
|
SNLog("Ignoring message with invalid sender.")
|
||||||
throw HTTP.Error.parsingFailed
|
throw HTTPError.parsingFailed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -54,8 +54,8 @@ public enum OpenGroupAPI {
|
||||||
.defaulting(to: [])
|
.defaulting(to: [])
|
||||||
|
|
||||||
// Generate the requests
|
// Generate the requests
|
||||||
let requestResponseType: [BatchRequestInfoType] = [
|
let requestResponseType: [BatchRequest.Info] = [
|
||||||
BatchRequestInfo(
|
BatchRequest.Info(
|
||||||
request: Request<NoBody, Endpoint>(
|
request: Request<NoBody, Endpoint>(
|
||||||
server: server,
|
server: server,
|
||||||
endpoint: .capabilities
|
endpoint: .capabilities
|
||||||
|
@ -71,7 +71,7 @@ public enum OpenGroupAPI {
|
||||||
.filter(OpenGroup.Columns.roomToken != "")
|
.filter(OpenGroup.Columns.roomToken != "")
|
||||||
.fetchAll(db))
|
.fetchAll(db))
|
||||||
.defaulting(to: [])
|
.defaulting(to: [])
|
||||||
.flatMap { openGroup -> [BatchRequestInfoType] in
|
.flatMap { openGroup -> [BatchRequest.Info] in
|
||||||
let shouldRetrieveRecentMessages: Bool = (
|
let shouldRetrieveRecentMessages: Bool = (
|
||||||
openGroup.sequenceNumber == 0 || (
|
openGroup.sequenceNumber == 0 || (
|
||||||
// If it's the first poll for this launch and it's been longer than
|
// If it's the first poll for this launch and it's been longer than
|
||||||
|
@ -83,14 +83,14 @@ public enum OpenGroupAPI {
|
||||||
)
|
)
|
||||||
|
|
||||||
return [
|
return [
|
||||||
BatchRequestInfo(
|
BatchRequest.Info(
|
||||||
request: Request<NoBody, Endpoint>(
|
request: Request<NoBody, Endpoint>(
|
||||||
server: server,
|
server: server,
|
||||||
endpoint: .roomPollInfo(openGroup.roomToken, openGroup.infoUpdates)
|
endpoint: .roomPollInfo(openGroup.roomToken, openGroup.infoUpdates)
|
||||||
),
|
),
|
||||||
responseType: RoomPollInfo.self
|
responseType: RoomPollInfo.self
|
||||||
),
|
),
|
||||||
BatchRequestInfo(
|
BatchRequest.Info(
|
||||||
request: Request<NoBody, Endpoint>(
|
request: Request<NoBody, Endpoint>(
|
||||||
server: server,
|
server: server,
|
||||||
endpoint: (shouldRetrieveRecentMessages ?
|
endpoint: (shouldRetrieveRecentMessages ?
|
||||||
|
@ -113,7 +113,7 @@ public enum OpenGroupAPI {
|
||||||
!capabilities.contains(.blind) ? [] :
|
!capabilities.contains(.blind) ? [] :
|
||||||
[
|
[
|
||||||
// Inbox
|
// Inbox
|
||||||
BatchRequestInfo(
|
BatchRequest.Info(
|
||||||
request: Request<NoBody, Endpoint>(
|
request: Request<NoBody, Endpoint>(
|
||||||
server: server,
|
server: server,
|
||||||
endpoint: (lastInboxMessageId == 0 ?
|
endpoint: (lastInboxMessageId == 0 ?
|
||||||
|
@ -125,7 +125,7 @@ public enum OpenGroupAPI {
|
||||||
),
|
),
|
||||||
|
|
||||||
// Outbox
|
// Outbox
|
||||||
BatchRequestInfo(
|
BatchRequest.Info(
|
||||||
request: Request<NoBody, Endpoint>(
|
request: Request<NoBody, Endpoint>(
|
||||||
server: server,
|
server: server,
|
||||||
endpoint: (lastOutboxMessageId == 0 ?
|
endpoint: (lastOutboxMessageId == 0 ?
|
||||||
|
@ -151,7 +151,7 @@ public enum OpenGroupAPI {
|
||||||
private static func batch(
|
private static func batch(
|
||||||
_ db: Database,
|
_ db: Database,
|
||||||
server: String,
|
server: String,
|
||||||
requests: [BatchRequestInfoType],
|
requests: [BatchRequest.Info],
|
||||||
using dependencies: SMKDependencies = SMKDependencies()
|
using dependencies: SMKDependencies = SMKDependencies()
|
||||||
) -> AnyPublisher<[Endpoint: (ResponseInfoType, Codable?)], Error> {
|
) -> AnyPublisher<[Endpoint: (ResponseInfoType, Codable?)], Error> {
|
||||||
let responseTypes = requests.map { $0.responseType }
|
let responseTypes = requests.map { $0.responseType }
|
||||||
|
@ -163,7 +163,7 @@ public enum OpenGroupAPI {
|
||||||
method: .post,
|
method: .post,
|
||||||
server: server,
|
server: server,
|
||||||
endpoint: Endpoint.batch,
|
endpoint: Endpoint.batch,
|
||||||
body: requestBody
|
body: BatchRequest(requests: requests)
|
||||||
),
|
),
|
||||||
using: dependencies
|
using: dependencies
|
||||||
)
|
)
|
||||||
|
@ -183,7 +183,7 @@ public enum OpenGroupAPI {
|
||||||
private static func sequence(
|
private static func sequence(
|
||||||
_ db: Database,
|
_ db: Database,
|
||||||
server: String,
|
server: String,
|
||||||
requests: [BatchRequestInfoType],
|
requests: [BatchRequest.Info],
|
||||||
using dependencies: SMKDependencies = SMKDependencies()
|
using dependencies: SMKDependencies = SMKDependencies()
|
||||||
) -> AnyPublisher<[Endpoint: (ResponseInfoType, Codable?)], Error> {
|
) -> AnyPublisher<[Endpoint: (ResponseInfoType, Codable?)], Error> {
|
||||||
let responseTypes = requests.map { $0.responseType }
|
let responseTypes = requests.map { $0.responseType }
|
||||||
|
@ -195,7 +195,7 @@ public enum OpenGroupAPI {
|
||||||
method: .post,
|
method: .post,
|
||||||
server: server,
|
server: server,
|
||||||
endpoint: Endpoint.sequence,
|
endpoint: Endpoint.sequence,
|
||||||
body: requestBody
|
body: BatchRequest(requests: requests)
|
||||||
),
|
),
|
||||||
using: dependencies
|
using: dependencies
|
||||||
)
|
)
|
||||||
|
@ -315,7 +315,7 @@ public enum OpenGroupAPI {
|
||||||
) -> AnyPublisher<(capabilities: (info: ResponseInfoType, data: Capabilities), room: (info: ResponseInfoType, data: Room)), Error> {
|
) -> AnyPublisher<(capabilities: (info: ResponseInfoType, data: Capabilities), room: (info: ResponseInfoType, data: Room)), Error> {
|
||||||
let requestResponseType: [BatchRequest.Info] = [
|
let requestResponseType: [BatchRequest.Info] = [
|
||||||
// Get the latest capabilities for the server (in case it's a new server or the cached ones are stale)
|
// 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>(
|
request: Request<NoBody, Endpoint>(
|
||||||
server: server,
|
server: server,
|
||||||
endpoint: .capabilities
|
endpoint: .capabilities
|
||||||
|
@ -324,7 +324,7 @@ public enum OpenGroupAPI {
|
||||||
),
|
),
|
||||||
|
|
||||||
// And the room info
|
// And the room info
|
||||||
BatchRequestInfo(
|
BatchRequest.Info(
|
||||||
request: Request<NoBody, Endpoint>(
|
request: Request<NoBody, Endpoint>(
|
||||||
server: server,
|
server: server,
|
||||||
endpoint: .room(roomToken)
|
endpoint: .room(roomToken)
|
||||||
|
@ -351,13 +351,13 @@ public enum OpenGroupAPI {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.map { _, value in value }
|
.map { _, value in value }
|
||||||
let maybeRoom: (info: OnionRequestResponseInfoType, data: Room?)? = maybeRoomResponse
|
let maybeRoom: (info: ResponseInfoType, data: Room?)? = maybeRoomResponse
|
||||||
.map { info, data in (info, (data as? BatchSubResponse<Room>)?.body) }
|
.map { info, data in (info, (data as? HTTP.BatchSubResponse<Room>)?.body) }
|
||||||
|
|
||||||
guard
|
guard
|
||||||
let capabilitiesInfo: OnionRequestResponseInfoType = maybeCapabilities?.info,
|
let capabilitiesInfo: ResponseInfoType = maybeCapabilities?.info,
|
||||||
let capabilities: Capabilities = maybeCapabilities?.data,
|
let capabilities: Capabilities = maybeCapabilities?.data,
|
||||||
let roomInfo: OnionRequestResponseInfoType = maybeRoom?.info,
|
let roomInfo: ResponseInfoType = maybeRoom?.info,
|
||||||
let room: Room = maybeRoom?.data
|
let room: Room = maybeRoom?.data
|
||||||
else {
|
else {
|
||||||
return Fail(error: HTTPError.parsingFailed)
|
return Fail(error: HTTPError.parsingFailed)
|
||||||
|
@ -383,7 +383,7 @@ public enum OpenGroupAPI {
|
||||||
) -> AnyPublisher<(capabilities: (info: ResponseInfoType, data: Capabilities), rooms: (info: ResponseInfoType, data: [Room])), Error> {
|
) -> AnyPublisher<(capabilities: (info: ResponseInfoType, data: Capabilities), rooms: (info: ResponseInfoType, data: [Room])), Error> {
|
||||||
let requestResponseType: [BatchRequest.Info] = [
|
let requestResponseType: [BatchRequest.Info] = [
|
||||||
// Get the latest capabilities for the server (in case it's a new server or the cached ones are stale)
|
// 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>(
|
request: Request<NoBody, Endpoint>(
|
||||||
server: server,
|
server: server,
|
||||||
endpoint: .capabilities
|
endpoint: .capabilities
|
||||||
|
@ -392,7 +392,7 @@ public enum OpenGroupAPI {
|
||||||
),
|
),
|
||||||
|
|
||||||
// And the room info
|
// And the room info
|
||||||
BatchRequestInfo(
|
BatchRequest.Info(
|
||||||
request: Request<NoBody, Endpoint>(
|
request: Request<NoBody, Endpoint>(
|
||||||
server: server,
|
server: server,
|
||||||
endpoint: .rooms
|
endpoint: .rooms
|
||||||
|
@ -419,13 +419,13 @@ public enum OpenGroupAPI {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.map { _, value in value }
|
.map { _, value in value }
|
||||||
let maybeRooms: (info: OnionRequestResponseInfoType, data: [Room]?)? = maybeRoomResponse
|
let maybeRooms: (info: ResponseInfoType, data: [Room]?)? = maybeRoomResponse
|
||||||
.map { info, data in (info, (data as? BatchSubResponse<[Room]>)?.body) }
|
.map { info, data in (info, (data as? HTTP.BatchSubResponse<[Room]>)?.body) }
|
||||||
|
|
||||||
guard
|
guard
|
||||||
let capabilitiesInfo: OnionRequestResponseInfoType = maybeCapabilities?.info,
|
let capabilitiesInfo: ResponseInfoType = maybeCapabilities?.info,
|
||||||
let capabilities: Capabilities = maybeCapabilities?.data,
|
let capabilities: Capabilities = maybeCapabilities?.data,
|
||||||
let roomsInfo: OnionRequestResponseInfoType = maybeRooms?.info,
|
let roomsInfo: ResponseInfoType = maybeRooms?.info,
|
||||||
let rooms: [Room] = maybeRooms?.data
|
let rooms: [Room] = maybeRooms?.data
|
||||||
else {
|
else {
|
||||||
return Fail(error: HTTPError.parsingFailed)
|
return Fail(error: HTTPError.parsingFailed)
|
||||||
|
@ -1239,16 +1239,16 @@ public enum OpenGroupAPI {
|
||||||
)
|
)
|
||||||
|
|
||||||
// Generate the requests
|
// Generate the requests
|
||||||
let requestResponseType: [BatchRequestInfoType] = [
|
let requestResponseType: [BatchRequest.Info] = [
|
||||||
BatchRequestInfo(
|
BatchRequest.Info(
|
||||||
request: Request(
|
request: Request<UserBanRequest, Endpoint>(
|
||||||
method: .post,
|
method: .post,
|
||||||
server: server,
|
server: server,
|
||||||
endpoint: .userBan(sessionId),
|
endpoint: .userBan(sessionId),
|
||||||
body: banRequestBody
|
body: banRequestBody
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
BatchRequestInfo(
|
BatchRequest.Info(
|
||||||
request: Request<NoBody, Endpoint>(
|
request: Request<NoBody, Endpoint>(
|
||||||
method: .delete,
|
method: .delete,
|
||||||
server: server,
|
server: server,
|
||||||
|
@ -1390,10 +1390,10 @@ public enum OpenGroupAPI {
|
||||||
|
|
||||||
updatedRequest.allHTTPHeaderFields = (request.allHTTPHeaderFields ?? [:])
|
updatedRequest.allHTTPHeaderFields = (request.allHTTPHeaderFields ?? [:])
|
||||||
.updated(with: [
|
.updated(with: [
|
||||||
Header.sogsPubKey.rawValue: signResult.publicKey,
|
HTTPHeader.sogsPubKey: signResult.publicKey,
|
||||||
Header.sogsTimestamp.rawValue: "\(timestamp)",
|
HTTPHeader.sogsTimestamp: "\(timestamp)",
|
||||||
Header.sogsNonce.rawValue: nonce.base64EncodedString(),
|
HTTPHeader.sogsNonce: nonce.base64EncodedString(),
|
||||||
Header.sogsSignature.rawValue: signResult.signature.toBase64()
|
HTTPHeader.sogsSignature: signResult.signature.toBase64()
|
||||||
])
|
])
|
||||||
|
|
||||||
return updatedRequest
|
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.
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import SessionUtilitiesKit
|
||||||
|
|
||||||
extension OpenGroupAPI {
|
extension OpenGroupAPI {
|
||||||
public enum Endpoint: EndpointType {
|
public enum Endpoint: EndpointType {
|
||||||
|
@ -58,7 +59,7 @@ extension OpenGroupAPI {
|
||||||
case userUnban(String)
|
case userUnban(String)
|
||||||
case userModerator(String)
|
case userModerator(String)
|
||||||
|
|
||||||
var path: String {
|
public var path: String {
|
||||||
switch self {
|
switch self {
|
||||||
// Utility
|
// 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)
|
).insert(db)
|
||||||
|
|
||||||
// Start polling
|
// Start polling
|
||||||
ClosedGroupPoller.shared.startPolling(for: groupPublicKey)
|
ClosedGroupPoller.shared.startIfNeeded(for: groupPublicKey)
|
||||||
|
|
||||||
// Notify the PN server
|
// Notify the PN server
|
||||||
let _ = PushNotificationAPI.performOperation(.subscribe, for: groupPublicKey, publicKey: getUserHexEncodedPublicKey(db))
|
let _ = PushNotificationAPI.performOperation(.subscribe, for: groupPublicKey, publicKey: getUserHexEncodedPublicKey(db))
|
||||||
|
|
|
@ -83,7 +83,7 @@ extension MessageSender {
|
||||||
.map { memberId -> MessageSender.PreparedSendData in
|
.map { memberId -> MessageSender.PreparedSendData in
|
||||||
try MessageSender.preparedSendData(
|
try MessageSender.preparedSendData(
|
||||||
db,
|
db,
|
||||||
message: LegacyClosedGroupControlMessage(
|
message: ClosedGroupControlMessage(
|
||||||
kind: .new(
|
kind: .new(
|
||||||
publicKey: Data(hex: groupPublicKey),
|
publicKey: Data(hex: groupPublicKey),
|
||||||
name: name,
|
name: name,
|
||||||
|
@ -99,7 +99,7 @@ extension MessageSender {
|
||||||
// the 'ClosedGroup' object we created
|
// the 'ClosedGroup' object we created
|
||||||
sentTimestampMs: UInt64(floor(formationTimestamp * 1000))
|
sentTimestampMs: UInt64(floor(formationTimestamp * 1000))
|
||||||
),
|
),
|
||||||
to: .contact(publicKey: memberId),
|
to: .contact(publicKey: memberId, namespace: .default),
|
||||||
interactionId: nil
|
interactionId: nil
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -263,7 +263,7 @@ extension MessageSender {
|
||||||
threadId: thread.id,
|
threadId: thread.id,
|
||||||
authorId: userPublicKey,
|
authorId: userPublicKey,
|
||||||
variant: .infoClosedGroupUpdated,
|
variant: .infoClosedGroupUpdated,
|
||||||
body: LegacyClosedGroupControlMessage.Kind
|
body: ClosedGroupControlMessage.Kind
|
||||||
.nameChange(name: name)
|
.nameChange(name: name)
|
||||||
.infoMessage(db, sender: userPublicKey),
|
.infoMessage(db, sender: userPublicKey),
|
||||||
timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000))
|
timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000))
|
||||||
|
@ -274,7 +274,7 @@ extension MessageSender {
|
||||||
// Send the update to the group
|
// Send the update to the group
|
||||||
try MessageSender.send(
|
try MessageSender.send(
|
||||||
db,
|
db,
|
||||||
message: LegacyClosedGroupControlMessage(kind: .nameChange(name: name)),
|
message: ClosedGroupControlMessage(kind: .nameChange(name: name)),
|
||||||
interactionId: interactionId,
|
interactionId: interactionId,
|
||||||
in: thread
|
in: thread
|
||||||
)
|
)
|
||||||
|
@ -493,7 +493,7 @@ extension MessageSender {
|
||||||
preparedSendData: try MessageSender
|
preparedSendData: try MessageSender
|
||||||
.preparedSendData(
|
.preparedSendData(
|
||||||
db,
|
db,
|
||||||
message: LegacyClosedGroupControlMessage(
|
message: ClosedGroupControlMessage(
|
||||||
kind: .membersRemoved(
|
kind: .membersRemoved(
|
||||||
members: removedMembers.map { Data(hex: $0) }
|
members: removedMembers.map { Data(hex: $0) }
|
||||||
)
|
)
|
||||||
|
@ -546,7 +546,7 @@ extension MessageSender {
|
||||||
threadId: thread.id,
|
threadId: thread.id,
|
||||||
authorId: userPublicKey,
|
authorId: userPublicKey,
|
||||||
variant: .infoClosedGroupCurrentUserLeft,
|
variant: .infoClosedGroupCurrentUserLeft,
|
||||||
body: LegacyClosedGroupControlMessage.Kind
|
body: ClosedGroupControlMessage.Kind
|
||||||
.memberLeft
|
.memberLeft
|
||||||
.infoMessage(db, sender: userPublicKey),
|
.infoMessage(db, sender: userPublicKey),
|
||||||
timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000))
|
timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000))
|
||||||
|
@ -561,7 +561,7 @@ extension MessageSender {
|
||||||
sendData = try MessageSender
|
sendData = try MessageSender
|
||||||
.preparedSendData(
|
.preparedSendData(
|
||||||
db,
|
db,
|
||||||
message: LegacyClosedGroupControlMessage(
|
message: ClosedGroupControlMessage(
|
||||||
kind: .memberLeft
|
kind: .memberLeft
|
||||||
),
|
),
|
||||||
to: try Message.Destination.from(db, thread: thread),
|
to: try Message.Destination.from(db, thread: thread),
|
||||||
|
|
|
@ -85,8 +85,8 @@ extension MessageSender {
|
||||||
|
|
||||||
let threadId: String = {
|
let threadId: String = {
|
||||||
switch destination {
|
switch destination {
|
||||||
case .contact(let publicKey): return publicKey
|
case .contact(let publicKey, _): return publicKey
|
||||||
case .closedGroup(let groupPublicKey): return groupPublicKey
|
case .closedGroup(let groupPublicKey, _): return groupPublicKey
|
||||||
case .openGroup(let roomToken, let server, _, _, _):
|
case .openGroup(let roomToken, let server, _, _, _):
|
||||||
return OpenGroup.idFor(roomToken: roomToken, server: 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
|
// 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
|
// 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)
|
// 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)
|
return Fail(error: StorageError.generic)
|
||||||
.eraseToAnyPublisher()
|
.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
@ -205,41 +208,31 @@ extension MessageSender {
|
||||||
to: legacyDestination,
|
to: legacyDestination,
|
||||||
interactionId: nil
|
interactionId: nil
|
||||||
)
|
)
|
||||||
|
|
||||||
when(
|
let userConfigSendData: [PreparedSendData] = try userConfigMessageChanges
|
||||||
resolved: try userConfigMessageChanges.map { message in
|
.map { message in
|
||||||
try MessageSender
|
try MessageSender.preparedSendData(
|
||||||
.sendImmediate(
|
db,
|
||||||
db,
|
message: message,
|
||||||
message: message,
|
to: destination,
|
||||||
to: destination,
|
interactionId: nil
|
||||||
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
|
/// We want to avoid blocking the db write thread so we dispatch the API call to a different thread
|
||||||
return Just(())
|
return Just(())
|
||||||
.setFailureType(to: Error.self)
|
.setFailureType(to: Error.self)
|
||||||
.receive(on: DispatchQueue.global(qos: .userInitiated))
|
.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()
|
.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,10 +23,6 @@ public final class MessageSender {
|
||||||
let plaintext: Data?
|
let plaintext: Data?
|
||||||
let ciphertext: Data?
|
let ciphertext: Data?
|
||||||
|
|
||||||
// TODO: Replace these with the target namespaces
|
|
||||||
let isClosedGroupMessage: Bool
|
|
||||||
let isConfigMessage: Bool
|
|
||||||
|
|
||||||
private init(
|
private init(
|
||||||
shouldSend: Bool,
|
shouldSend: Bool,
|
||||||
message: Message?,
|
message: Message?,
|
||||||
|
@ -36,9 +32,7 @@ public final class MessageSender {
|
||||||
totalAttachmentsUploaded: Int = 0,
|
totalAttachmentsUploaded: Int = 0,
|
||||||
snodeMessage: SnodeMessage?,
|
snodeMessage: SnodeMessage?,
|
||||||
plaintext: Data?,
|
plaintext: Data?,
|
||||||
ciphertext: Data?,
|
ciphertext: Data?
|
||||||
isClosedGroupMessage: Bool,
|
|
||||||
isConfigMessage: Bool
|
|
||||||
) {
|
) {
|
||||||
self.shouldSend = shouldSend
|
self.shouldSend = shouldSend
|
||||||
|
|
||||||
|
@ -51,8 +45,6 @@ public final class MessageSender {
|
||||||
self.snodeMessage = snodeMessage
|
self.snodeMessage = snodeMessage
|
||||||
self.plaintext = plaintext
|
self.plaintext = plaintext
|
||||||
self.ciphertext = ciphertext
|
self.ciphertext = ciphertext
|
||||||
self.isClosedGroupMessage = isClosedGroupMessage
|
|
||||||
self.isConfigMessage = isConfigMessage
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The default constructor creats an instance that doesn't actually send a message
|
// 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.snodeMessage = nil
|
||||||
self.plaintext = nil
|
self.plaintext = nil
|
||||||
self.ciphertext = 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
|
/// 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,
|
destination: Message.Destination,
|
||||||
interactionId: Int64?,
|
interactionId: Int64?,
|
||||||
isSyncMessage: Bool?,
|
isSyncMessage: Bool?,
|
||||||
snodeMessage: SnodeMessage,
|
snodeMessage: SnodeMessage
|
||||||
isClosedGroupMessage: Bool,
|
|
||||||
isConfigMessage: Bool
|
|
||||||
) {
|
) {
|
||||||
self.shouldSend = true
|
self.shouldSend = true
|
||||||
|
|
||||||
|
@ -93,8 +81,6 @@ public final class MessageSender {
|
||||||
self.snodeMessage = snodeMessage
|
self.snodeMessage = snodeMessage
|
||||||
self.plaintext = nil
|
self.plaintext = nil
|
||||||
self.ciphertext = nil
|
self.ciphertext = nil
|
||||||
self.isClosedGroupMessage = isClosedGroupMessage
|
|
||||||
self.isConfigMessage = isConfigMessage
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// This should be used to send a message to open group conversations
|
/// This should be used to send a message to open group conversations
|
||||||
|
@ -115,8 +101,6 @@ public final class MessageSender {
|
||||||
self.snodeMessage = nil
|
self.snodeMessage = nil
|
||||||
self.plaintext = plaintext
|
self.plaintext = plaintext
|
||||||
self.ciphertext = nil
|
self.ciphertext = nil
|
||||||
self.isClosedGroupMessage = false
|
|
||||||
self.isConfigMessage = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// This should be used to send a message to an open group inbox
|
/// 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.snodeMessage = nil
|
||||||
self.plaintext = nil
|
self.plaintext = nil
|
||||||
self.ciphertext = ciphertext
|
self.ciphertext = ciphertext
|
||||||
self.isClosedGroupMessage = false
|
|
||||||
self.isConfigMessage = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Mutation
|
// MARK: - Mutation
|
||||||
|
@ -153,9 +135,7 @@ public final class MessageSender {
|
||||||
totalAttachmentsUploaded: fileIds.count,
|
totalAttachmentsUploaded: fileIds.count,
|
||||||
snodeMessage: snodeMessage,
|
snodeMessage: snodeMessage,
|
||||||
plaintext: plaintext,
|
plaintext: plaintext,
|
||||||
ciphertext: ciphertext,
|
ciphertext: ciphertext
|
||||||
isClosedGroupMessage: isClosedGroupMessage,
|
|
||||||
isConfigMessage: isConfigMessage
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -333,18 +313,15 @@ public final class MessageSender {
|
||||||
// Wrap the result
|
// Wrap the result
|
||||||
let kind: SNProtoEnvelope.SNProtoEnvelopeType
|
let kind: SNProtoEnvelope.SNProtoEnvelopeType
|
||||||
let senderPublicKey: String
|
let senderPublicKey: String
|
||||||
let namespace: SnodeAPI.Namespace
|
|
||||||
|
|
||||||
switch destination {
|
switch destination {
|
||||||
case .contact(_, let targetNamespace):
|
case .contact:
|
||||||
kind = .sessionMessage
|
kind = .sessionMessage
|
||||||
senderPublicKey = ""
|
senderPublicKey = ""
|
||||||
namespace = targetNamespace
|
|
||||||
|
|
||||||
case .closedGroup(let groupPublicKey, let targetNamespace):
|
case .closedGroup(let groupPublicKey, _):
|
||||||
kind = .closedGroupMessage
|
kind = .closedGroupMessage
|
||||||
senderPublicKey = groupPublicKey
|
senderPublicKey = groupPublicKey
|
||||||
namespace = targetNamespace
|
|
||||||
|
|
||||||
case .openGroup, .openGroupInbox: preconditionFailure()
|
case .openGroup, .openGroupInbox: preconditionFailure()
|
||||||
}
|
}
|
||||||
|
@ -384,9 +361,7 @@ public final class MessageSender {
|
||||||
destination: destination,
|
destination: destination,
|
||||||
interactionId: interactionId,
|
interactionId: interactionId,
|
||||||
isSyncMessage: isSyncMessage,
|
isSyncMessage: isSyncMessage,
|
||||||
snodeMessage: snodeMessage,
|
snodeMessage: snodeMessage
|
||||||
isClosedGroupMessage: (kind == .closedGroupMessage),
|
|
||||||
isConfigMessage: (message is ConfigurationMessage)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -667,10 +642,7 @@ public final class MessageSender {
|
||||||
return SnodeAPI
|
return SnodeAPI
|
||||||
.sendMessage(
|
.sendMessage(
|
||||||
snodeMessage,
|
snodeMessage,
|
||||||
in: (data.isClosedGroupMessage ?
|
in: destination.namespace
|
||||||
.legacyClosedGroup :
|
|
||||||
.default
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
.subscribe(on: DispatchQueue.global(qos: .default))
|
.subscribe(on: DispatchQueue.global(qos: .default))
|
||||||
.flatMap { result, totalCount -> AnyPublisher<Bool, Error> in
|
.flatMap { result, totalCount -> AnyPublisher<Bool, Error> in
|
||||||
|
@ -1014,7 +986,7 @@ public final class MessageSender {
|
||||||
data: try prepareSendToSnodeDestination(
|
data: try prepareSendToSnodeDestination(
|
||||||
db,
|
db,
|
||||||
message: message,
|
message: message,
|
||||||
to: .contact(publicKey: userPublicKey),
|
to: .contact(publicKey: userPublicKey, namespace: namespace),
|
||||||
interactionId: interactionId,
|
interactionId: interactionId,
|
||||||
userPublicKey: userPublicKey,
|
userPublicKey: userPublicKey,
|
||||||
messageSendTimestamp: Int64(floor(Date().timeIntervalSince1970 * 1000)),
|
messageSendTimestamp: Int64(floor(Date().timeIntervalSince1970 * 1000)),
|
||||||
|
|
|
@ -90,7 +90,7 @@ public enum PushNotificationAPI {
|
||||||
let url = URL(string: "\(server)/unregister")!
|
let url = URL(string: "\(server)/unregister")!
|
||||||
var request: URLRequest = URLRequest(url: url)
|
var request: URLRequest = URLRequest(url: url)
|
||||||
request.httpMethod = "POST"
|
request.httpMethod = "POST"
|
||||||
request.allHTTPHeaderFields = [ Header.contentType.rawValue: "application/json" ]
|
request.allHTTPHeaderFields = [ HTTPHeader.contentType: "application/json" ]
|
||||||
request.httpBody = body
|
request.httpBody = body
|
||||||
|
|
||||||
return OnionRequestAPI
|
return OnionRequestAPI
|
||||||
|
@ -144,7 +144,7 @@ public enum PushNotificationAPI {
|
||||||
let url = URL(string: "\(server)/register")!
|
let url = URL(string: "\(server)/register")!
|
||||||
var request: URLRequest = URLRequest(url: url)
|
var request: URLRequest = URLRequest(url: url)
|
||||||
request.httpMethod = "POST"
|
request.httpMethod = "POST"
|
||||||
request.allHTTPHeaderFields = [ Header.contentType.rawValue: "application/json" ]
|
request.allHTTPHeaderFields = [ HTTPHeader.contentType: "application/json" ]
|
||||||
request.httpBody = body
|
request.httpBody = body
|
||||||
|
|
||||||
return Publishers
|
return Publishers
|
||||||
|
@ -227,7 +227,7 @@ public enum PushNotificationAPI {
|
||||||
let url = URL(string: "\(server)/\(operation.endpoint)")!
|
let url = URL(string: "\(server)/\(operation.endpoint)")!
|
||||||
var request: URLRequest = URLRequest(url: url)
|
var request: URLRequest = URLRequest(url: url)
|
||||||
request.httpMethod = "POST"
|
request.httpMethod = "POST"
|
||||||
request.allHTTPHeaderFields = [ Header.contentType.rawValue: "application/json" ]
|
request.allHTTPHeaderFields = [ HTTPHeader.contentType: "application/json" ]
|
||||||
request.httpBody = body
|
request.httpBody = body
|
||||||
|
|
||||||
return OnionRequestAPI
|
return OnionRequestAPI
|
||||||
|
@ -272,7 +272,7 @@ public enum PushNotificationAPI {
|
||||||
let url = URL(string: "\(server)/notify")!
|
let url = URL(string: "\(server)/notify")!
|
||||||
var request: URLRequest = URLRequest(url: url)
|
var request: URLRequest = URLRequest(url: url)
|
||||||
request.httpMethod = "POST"
|
request.httpMethod = "POST"
|
||||||
request.allHTTPHeaderFields = [ Header.contentType.rawValue: "application/json" ]
|
request.allHTTPHeaderFields = [ HTTPHeader.contentType: "application/json" ]
|
||||||
request.httpBody = body
|
request.httpBody = body
|
||||||
|
|
||||||
return OnionRequestAPI
|
return OnionRequestAPI
|
||||||
|
|
|
@ -8,7 +8,7 @@ import SessionUtilitiesKit
|
||||||
|
|
||||||
extension OpenGroupAPI {
|
extension OpenGroupAPI {
|
||||||
public final class Poller {
|
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 let server: String
|
||||||
private var timer: Timer? = nil
|
private var timer: Timer? = nil
|
||||||
|
@ -283,7 +283,7 @@ extension OpenGroupAPI {
|
||||||
.filter { endpoint, endpointResponse in
|
.filter { endpoint, endpointResponse in
|
||||||
switch endpoint {
|
switch endpoint {
|
||||||
case .capabilities:
|
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.")
|
SNLog("Open group polling failed due to invalid capability data.")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
@ -291,8 +291,8 @@ extension OpenGroupAPI {
|
||||||
return true
|
return true
|
||||||
|
|
||||||
case .roomPollInfo(let roomToken, _):
|
case .roomPollInfo(let roomToken, _):
|
||||||
guard (endpointResponse.data as? BatchSubResponse<RoomPollInfo>)?.body != nil else {
|
guard (endpointResponse.data as? HTTP.BatchSubResponse<RoomPollInfo>)?.body != nil else {
|
||||||
switch (endpointResponse.data as? BatchSubResponse<RoomPollInfo>)?.code {
|
switch (endpointResponse.data as? HTTP.BatchSubResponse<RoomPollInfo>)?.code {
|
||||||
case 404: SNLog("Open group polling failed to retrieve info for unknown room '\(roomToken)'.")
|
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.")
|
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, _):
|
case .roomMessagesRecent(let roomToken), .roomMessagesBefore(let roomToken, _), .roomMessagesSince(let roomToken, _):
|
||||||
guard
|
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
|
let responseBody: [Failable<Message>] = responseData.body
|
||||||
else {
|
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)'.")
|
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.")
|
default: SNLog("Open group polling failed due to invalid messages data.")
|
||||||
}
|
}
|
||||||
|
@ -325,7 +325,7 @@ extension OpenGroupAPI {
|
||||||
|
|
||||||
case .inbox, .inboxSince, .outbox, .outboxSince:
|
case .inbox, .inboxSince, .outbox, .outboxSince:
|
||||||
guard
|
guard
|
||||||
let responseData: BatchSubResponse<[DirectMessage]?> = endpointResponse.data as? BatchSubResponse<[DirectMessage]?>,
|
let responseData: HTTP.BatchSubResponse<[DirectMessage]?> = endpointResponse.data as? HTTP.BatchSubResponse<[DirectMessage]?>,
|
||||||
!responseData.failedToParseBody
|
!responseData.failedToParseBody
|
||||||
else {
|
else {
|
||||||
SNLog("Open group polling failed due to invalid inbox/outbox data.")
|
SNLog("Open group polling failed due to invalid inbox/outbox data.")
|
||||||
|
@ -383,7 +383,7 @@ extension OpenGroupAPI {
|
||||||
switch endpoint {
|
switch endpoint {
|
||||||
case .capabilities:
|
case .capabilities:
|
||||||
guard
|
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
|
let responseBody: Capabilities = responseData.body
|
||||||
else { return false }
|
else { return false }
|
||||||
|
|
||||||
|
@ -391,7 +391,7 @@ extension OpenGroupAPI {
|
||||||
|
|
||||||
case .roomPollInfo(let roomToken, _):
|
case .roomPollInfo(let roomToken, _):
|
||||||
guard
|
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
|
let responseBody: RoomPollInfo = responseData.body
|
||||||
else { return false }
|
else { return false }
|
||||||
guard let existingOpenGroup: OpenGroup = currentInfo?.groups.first(where: { $0.roomToken == roomToken }) else {
|
guard let existingOpenGroup: OpenGroup = currentInfo?.groups.first(where: { $0.roomToken == roomToken }) else {
|
||||||
|
@ -428,7 +428,7 @@ extension OpenGroupAPI {
|
||||||
switch endpoint {
|
switch endpoint {
|
||||||
case .capabilities:
|
case .capabilities:
|
||||||
guard
|
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
|
let responseBody: Capabilities = responseData.body
|
||||||
else { return }
|
else { return }
|
||||||
|
|
||||||
|
@ -440,7 +440,7 @@ extension OpenGroupAPI {
|
||||||
|
|
||||||
case .roomPollInfo(let roomToken, _):
|
case .roomPollInfo(let roomToken, _):
|
||||||
guard
|
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
|
let responseBody: RoomPollInfo = responseData.body
|
||||||
else { return }
|
else { return }
|
||||||
|
|
||||||
|
@ -455,7 +455,7 @@ extension OpenGroupAPI {
|
||||||
|
|
||||||
case .roomMessagesRecent(let roomToken), .roomMessagesBefore(let roomToken, _), .roomMessagesSince(let roomToken, _):
|
case .roomMessagesRecent(let roomToken), .roomMessagesBefore(let roomToken, _), .roomMessagesSince(let roomToken, _):
|
||||||
guard
|
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
|
let responseBody: [Failable<Message>] = responseData.body
|
||||||
else { return }
|
else { return }
|
||||||
|
|
||||||
|
@ -469,7 +469,7 @@ extension OpenGroupAPI {
|
||||||
|
|
||||||
case .inbox, .inboxSince, .outbox, .outboxSince:
|
case .inbox, .inboxSince, .outbox, .outboxSince:
|
||||||
guard
|
guard
|
||||||
let responseData: BatchSubResponse<[DirectMessage]?> = endpointResponse.data as? BatchSubResponse<[DirectMessage]?>,
|
let responseData: HTTP.BatchSubResponse<[DirectMessage]?> = endpointResponse.data as? HTTP.BatchSubResponse<[DirectMessage]?>,
|
||||||
!responseData.failedToParseBody
|
!responseData.failedToParseBody
|
||||||
else { return }
|
else { return }
|
||||||
|
|
||||||
|
|
|
@ -191,12 +191,12 @@ public class Poller {
|
||||||
.setFailureType(to: Error.self)
|
.setFailureType(to: Error.self)
|
||||||
.eraseToAnyPublisher()
|
.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
|
||||||
let pollerName: String = (
|
let pollerName: String = (
|
||||||
poller?.pollerName(for: publicKey) ??
|
poller?.pollerName(for: publicKey) ??
|
||||||
"poller with public key \(publicKey)"
|
"poller with public key \(publicKey)"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Fetch the messages
|
// Fetch the messages
|
||||||
return SnodeAPI.getMessages(in: namespaces, from: snode, associatedWith: publicKey)
|
return SnodeAPI.getMessages(in: namespaces, from: snode, associatedWith: publicKey)
|
||||||
.flatMap { namespacedResults -> AnyPublisher<Void, Error> in
|
.flatMap { namespacedResults -> AnyPublisher<Void, Error> in
|
||||||
|
@ -239,8 +239,8 @@ public class Poller {
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
switch error {
|
switch error {
|
||||||
// Ignore duplicate & selfSend message errors (and don't bother logging
|
// Ignore duplicate & selfSend message errors (and don't bother logging
|
||||||
// them as there will be a lot since we each service node duplicates messages)
|
// them as there will be a lot since we each service node duplicates messages)
|
||||||
case DatabaseError.SQLITE_CONSTRAINT_UNIQUE,
|
case DatabaseError.SQLITE_CONSTRAINT_UNIQUE,
|
||||||
MessageReceiverError.duplicateMessage,
|
MessageReceiverError.duplicateMessage,
|
||||||
MessageReceiverError.duplicateControlMessage,
|
MessageReceiverError.duplicateControlMessage,
|
||||||
|
@ -252,9 +252,13 @@ public class Poller {
|
||||||
break
|
break
|
||||||
|
|
||||||
case DatabaseError.SQLITE_ABORT:
|
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
|
break
|
||||||
|
|
||||||
default: SNLog("Failed to deserialize envelope due to error: \(error).")
|
default: SNLog("Failed to deserialize envelope due to error: \(error).")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -265,33 +269,41 @@ public class Poller {
|
||||||
.forEach { threadId, threadMessages in
|
.forEach { threadId, threadMessages in
|
||||||
messageCount += threadMessages.count
|
messageCount += threadMessages.count
|
||||||
|
|
||||||
JobRunner.add(
|
let jobToRun: Job? = Job(
|
||||||
db,
|
variant: .messageReceive,
|
||||||
job: Job(
|
behaviour: .runOnce,
|
||||||
variant: .messageReceive,
|
threadId: threadId,
|
||||||
behaviour: .runOnce,
|
details: MessageReceiveJob.Details(
|
||||||
threadId: threadId,
|
messages: threadMessages.map { $0.messageInfo },
|
||||||
details: MessageReceiveJob.Details(
|
calledFromBackgroundPoller: calledFromBackgroundPoller
|
||||||
messages: threadMessages.map { $0.messageInfo },
|
|
||||||
calledFromBackgroundPoller: false
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
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 {
|
// Update the cached validity of the messages
|
||||||
SNLog("Received \(messages.count) new message\(messages.count == 1 ? "" : "s"), all duplicates - marking the hash we polled with as invalid")
|
try SnodeReceivedMessageInfo.handlePotentialDeletedOrInvalidHash(
|
||||||
|
db,
|
||||||
// Update the cached validity of the messages
|
potentiallyInvalidHashes: lastHashes,
|
||||||
try SnodeReceivedMessageInfo.handlePotentialDeletedOrInvalidHash(
|
otherKnownValidHashes: namespacedResults
|
||||||
db,
|
.compactMap { $0.value.data?.messages.map { $0.info.hash } }
|
||||||
potentiallyInvalidHashes: [lastHash],
|
.reduce([], +)
|
||||||
otherKnownValidHashes: messages.map { $0.info.hash }
|
)
|
||||||
)
|
}
|
||||||
}
|
else if !calledFromBackgroundPoller {
|
||||||
else {
|
SNLog("Received \(messageCount) new message\(messageCount == 1 ? "" : "s") in \(pollerName) (duplicates: \(allMessagesCount - messageCount))")
|
||||||
SNLog("Received \(messageCount) new message\(messageCount == 1 ? "" : "s") (duplicates: \(messages.count - messageCount))")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -5,23 +5,7 @@ import SessionUtilitiesKit
|
||||||
|
|
||||||
// MARK: - Decoding
|
// MARK: - Decoding
|
||||||
|
|
||||||
extension Dependencies {
|
|
||||||
static let userInfoKey: CodingUserInfoKey = CodingUserInfoKey(rawValue: "io.oxen.dependencies.codingOptions")!
|
|
||||||
}
|
|
||||||
|
|
||||||
public extension Data {
|
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 {
|
func removePadding() -> Data {
|
||||||
let bytes: [UInt8] = self.bytes
|
let bytes: [UInt8] = self.bytes
|
||||||
var paddingStart: Int = self.count
|
var paddingStart: Int = self.count
|
||||||
|
|
|
@ -78,6 +78,13 @@ public struct ProfileManager {
|
||||||
return data
|
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? {
|
private static func loadProfileData(with fileName: String) -> Data? {
|
||||||
let filePath: String = ProfileManager.profileAvatarFilepath(filename: fileName)
|
let filePath: String = ProfileManager.profileAvatarFilepath(filename: fileName)
|
||||||
|
|
||||||
|
@ -228,7 +235,7 @@ public struct ProfileManager {
|
||||||
return
|
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.")
|
OWSLogger.warn("Avatar data for \(profile.id) could not be decrypted.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -388,38 +395,22 @@ public struct ProfileManager {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If we have no image then we should succeed (database changes happen in the callback)
|
||||||
guard let data: Data = avatarImageData else {
|
guard let data: Data = avatarImageData else {
|
||||||
// If we have no image then we need to make sure to remove it from the profile
|
// Remove any cached avatar image value
|
||||||
Storage.shared.writeAsync { db in
|
let maybeExistingFileName: String? = Storage.shared
|
||||||
let existingProfile: Profile = Profile.fetchOrCreateCurrentUser(db)
|
.read { db in
|
||||||
|
try Profile
|
||||||
OWSLogger.verbose(existingProfile.profilePictureUrl != nil ?
|
.select(.profilePictureFileName)
|
||||||
"Updating local profile on service with cleared avatar." :
|
.asRequest(of: String.self)
|
||||||
"Updating local profile on service with no avatar."
|
.fetchOne(db)
|
||||||
)
|
|
||||||
|
|
||||||
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 }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
SNLog("Successfully updated service with profile.")
|
if let fileName: String = maybeExistingFileName {
|
||||||
|
profileAvatarCache.mutate { $0[fileName] = nil }
|
||||||
try success?(db, updatedProfile)
|
|
||||||
}
|
}
|
||||||
return
|
|
||||||
|
return success(nil, newProfileKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we have a new avatar image, we must first:
|
// If we have a new avatar image, we must first:
|
||||||
|
@ -447,7 +438,7 @@ public struct ProfileManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encrypt the avatar for upload
|
// 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.")
|
SNLog("Updating service with profile failed.")
|
||||||
failure?(.avatarEncryptionFailed)
|
failure?(.avatarEncryptionFailed)
|
||||||
return
|
return
|
||||||
|
|
|
@ -7,7 +7,7 @@ import SignalUtilitiesKit
|
||||||
import SessionUIKit
|
import SessionUIKit
|
||||||
import SignalCoreKit
|
import SignalCoreKit
|
||||||
|
|
||||||
final class ShareVC: UINavigationController, ShareViewDelegate {
|
final class ShareNavController: UINavigationController, ShareViewDelegate {
|
||||||
private var areVersionMigrationsComplete = false
|
private var areVersionMigrationsComplete = false
|
||||||
public static var attachmentPrepPublisher: AnyPublisher<[SignalAttachment], Error>?
|
public static var attachmentPrepPublisher: AnyPublisher<[SignalAttachment], Error>?
|
||||||
|
|
||||||
|
@ -183,7 +183,7 @@ final class ShareVC: UINavigationController, ShareViewDelegate {
|
||||||
|
|
||||||
private func showMainContent() {
|
private func showMainContent() {
|
||||||
let threadPickerVC: ThreadPickerVC = ThreadPickerVC()
|
let threadPickerVC: ThreadPickerVC = ThreadPickerVC()
|
||||||
threadPickerVC.shareVC = self
|
threadPickerVC.shareNavController = self
|
||||||
|
|
||||||
setViewControllers([ threadPickerVC ], animated: false)
|
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)
|
// * 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
|
// so in the case of file attachments we try to refine the attachment type
|
||||||
// using the file extension.
|
// using the file extension.
|
||||||
guard let srcUtiType = ShareVC.utiType(itemProvider: itemProvider) else {
|
guard let srcUtiType = ShareNavController.utiType(itemProvider: itemProvider) else {
|
||||||
let error = ShareViewControllerError.unsupportedMedia
|
let error = ShareViewControllerError.unsupportedMedia
|
||||||
return Fail(error: error)
|
return Fail(error: error)
|
||||||
.eraseToAnyPublisher()
|
.eraseToAnyPublisher()
|
||||||
|
@ -613,7 +613,7 @@ final class ShareVC: UINavigationController, ShareViewDelegate {
|
||||||
|
|
||||||
Logger.debug("building DataSource with url: \(url), utiType: \(utiType)")
|
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")
|
let error = ShareViewControllerError.assertionError(description: "Unable to read attachment data")
|
||||||
return Fail(error: error)
|
return Fail(error: error)
|
||||||
.eraseToAnyPublisher()
|
.eraseToAnyPublisher()
|
|
@ -92,12 +92,10 @@ final class SimplifiedConversationCell: UITableViewCell {
|
||||||
accentLineView.alpha = (cellViewModel.threadIsBlocked == true ? 1 : 0)
|
accentLineView.alpha = (cellViewModel.threadIsBlocked == true ? 1 : 0)
|
||||||
profilePictureView.update(
|
profilePictureView.update(
|
||||||
publicKey: cellViewModel.threadId,
|
publicKey: cellViewModel.threadId,
|
||||||
profile: cellViewModel.profile,
|
|
||||||
additionalProfile: cellViewModel.additionalProfile,
|
|
||||||
threadVariant: cellViewModel.threadVariant,
|
threadVariant: cellViewModel.threadVariant,
|
||||||
openGroupProfilePictureData: cellViewModel.openGroupProfilePictureData,
|
customImageData: cellViewModel.openGroupProfilePictureData,
|
||||||
useFallbackPicture: (cellViewModel.threadVariant == .openGroup && cellViewModel.openGroupProfilePictureData == nil),
|
profile: cellViewModel.profile,
|
||||||
showMultiAvatarForClosedGroup: true
|
additionalProfile: cellViewModel.additionalProfile
|
||||||
)
|
)
|
||||||
displayNameLabel.text = cellViewModel.displayName
|
displayNameLabel.text = cellViewModel.displayName
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,7 +13,7 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView
|
||||||
private var dataChangeObservable: DatabaseCancellable?
|
private var dataChangeObservable: DatabaseCancellable?
|
||||||
private var hasLoadedInitialData: Bool = false
|
private var hasLoadedInitialData: Bool = false
|
||||||
|
|
||||||
var shareVC: ShareVC?
|
var shareNavController: ShareNavController?
|
||||||
|
|
||||||
// MARK: - Intialization
|
// MARK: - Intialization
|
||||||
|
|
||||||
|
@ -182,9 +182,9 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView
|
||||||
messageText
|
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
|
// Resume database
|
||||||
NotificationCenter.default.post(name: Database.resumeNotification, object: self)
|
NotificationCenter.default.post(name: Database.resumeNotification, object: self)
|
||||||
|
|
||||||
|
|
|
@ -60,7 +60,7 @@ extension Snode {
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
SNLog("Failed to parse snode: \(error.localizedDescription).")
|
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
|
// MARK: - Convenience
|
||||||
|
|
||||||
public extension SnodeReceivedMessageInfo {
|
public extension SnodeReceivedMessageInfo {
|
||||||
private static func key(for snode: Snode, publicKey: String, namespace: Int) -> String {
|
private static func key(for snode: Snode, publicKey: String, namespace: SnodeAPI.Namespace) -> String {
|
||||||
guard namespace != SnodeAPI.defaultNamespace else {
|
guard namespace != .default else {
|
||||||
return "\(snode.address):\(snode.port).\(publicKey)"
|
return "\(snode.address):\(snode.port).\(publicKey)"
|
||||||
}
|
}
|
||||||
|
|
||||||
return "\(snode.address):\(snode.port).\(publicKey).\(namespace)"
|
return "\(snode.address):\(snode.port).\(publicKey).\(namespace.rawValue)"
|
||||||
}
|
}
|
||||||
|
|
||||||
init(
|
init(
|
||||||
snode: Snode,
|
snode: Snode,
|
||||||
publicKey: String,
|
publicKey: String,
|
||||||
namespace: Int,
|
namespace: SnodeAPI.Namespace,
|
||||||
hash: String,
|
hash: String,
|
||||||
expirationDateMs: Int64?
|
expirationDateMs: Int64?
|
||||||
) {
|
) {
|
||||||
|
@ -76,15 +76,15 @@ public extension SnodeReceivedMessageInfo {
|
||||||
// MARK: - GRDB Interactions
|
// MARK: - GRDB Interactions
|
||||||
|
|
||||||
public extension SnodeReceivedMessageInfo {
|
public extension SnodeReceivedMessageInfo {
|
||||||
static func pruneExpiredMessageHashInfo(for snode: Snode, namespace: Int, associatedWith publicKey: String) {
|
static func pruneExpiredMessageHashInfo(for snode: Snode, namespace: SnodeAPI.Namespace, associatedWith publicKey: String) {
|
||||||
// Delete any expired SnodeReceivedMessageInfo values associated to a specific node (even though
|
// Delete any expired SnodeReceivedMessageInfo values associated to a specific node (even
|
||||||
// this runs very quickly we fetch the rowIds we want to delete from a 'read' call to avoid
|
// though this runs very quickly we fetch the rowIds we want to delete from a 'read' call
|
||||||
// blocking the write queue since this method is called very frequently)
|
// to avoid blocking the write queue since this method is called very frequently)
|
||||||
let rowIds: [Int64] = Storage.shared
|
let rowIds: [Int64] = Storage.shared
|
||||||
.read { db in
|
.read { db in
|
||||||
// Only prune the hashes if new hashes exist for this Snode (if they don't then we don't want
|
// Only prune the hashes if new hashes exist for this Snode (if they don't then
|
||||||
// to clear out the legacy hashes)
|
// we don't want to clear out the legacy hashes)
|
||||||
let hasNonLegacyHash: Bool = try SnodeReceivedMessageInfo
|
let hasNonLegacyHash: Bool = SnodeReceivedMessageInfo
|
||||||
.filter(SnodeReceivedMessageInfo.Columns.key == key(for: snode, publicKey: publicKey, namespace: namespace))
|
.filter(SnodeReceivedMessageInfo.Columns.key == key(for: snode, publicKey: publicKey, namespace: namespace))
|
||||||
.isNotEmpty(db)
|
.isNotEmpty(db)
|
||||||
|
|
||||||
|
@ -111,10 +111,10 @@ public extension SnodeReceivedMessageInfo {
|
||||||
|
|
||||||
/// This method fetches the last non-expired hash from the database for message retrieval
|
/// 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
|
/// **Note:** This method uses a `write` instead of a read because there is a single write queue for the database and it's
|
||||||
/// this method to be called after the hash value has been updated but before the various `read` threads have been updated, resulting in a
|
/// very common for this method to be called after the hash value has been updated but before the various `read` threads
|
||||||
/// pointless fetch for data the app has already received
|
/// 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? {
|
static func fetchLastNotExpired(for snode: Snode, namespace: SnodeAPI.Namespace, associatedWith publicKey: String) -> SnodeReceivedMessageInfo? {
|
||||||
return Storage.shared.read { db in
|
return Storage.shared.read { db in
|
||||||
let nonLegacyHash: SnodeReceivedMessageInfo? = try SnodeReceivedMessageInfo
|
let nonLegacyHash: SnodeReceivedMessageInfo? = try SnodeReceivedMessageInfo
|
||||||
.filter(
|
.filter(
|
||||||
|
|
|
@ -3,6 +3,7 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
import GRDB
|
import GRDB
|
||||||
|
import SignalCoreKit
|
||||||
import SessionUtilitiesKit
|
import SessionUtilitiesKit
|
||||||
|
|
||||||
public enum GetSnodePoolJob: JobExecutor {
|
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