Added in missing code changes unrelated to closed groups rebuild

This commit is contained in:
Morgan Pretty 2022-12-08 14:21:38 +11:00
parent 70ff2b49f0
commit f1e9412c7a
169 changed files with 5005 additions and 4572 deletions

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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