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(
publicKey: call.sessionId,
threadVariant: .contact,
customImageData: nil,
profile: Profile.fetchOrCreate(id: call.sessionId),
threadVariant: .contact
additionalProfile: nil
)
displayNameLabel.text = call.contactName

View File

@ -1,9 +1,9 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import Combine
import GRDB
import DifferenceKit
import PromiseKit
import SessionUIKit
import SessionMessagingKit
import SignalUtilitiesKit
@ -220,7 +220,8 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat
cell.update(
with: SessionCell.Info(
id: displayInfo,
leftAccessory: .profile(displayInfo.profileId, displayInfo.profile),
position: Position.with(indexPath.row, count: membersAndZombies.count),
leftAccessory: .profile(id: displayInfo.profileId, profile: displayInfo.profile),
title: (
displayInfo.profile?.displayName() ??
Profile.truncated(id: displayInfo.profileId, threadVariant: .contact)
@ -231,10 +232,9 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat
.withRenderingMode(.alwaysTemplate),
customTint: .textSecondary
)
)
),
style: .edgeToEdge,
position: Position.with(indexPath.row, count: membersAndZombies.count)
),
styling: SessionCell.StyleInfo(backgroundStyle: .edgeToEdge)
)
)
return cell
@ -449,7 +449,7 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat
ModalActivityIndicatorViewController.present(fromViewController: navigationController) { _ in
Storage.shared
.writeAsync { db in
.writePublisherFlatMap { db -> AnyPublisher<Void, Error> in
if !updatedMemberIds.contains(userPublicKey) {
return try MessageSender.leave(db, groupPublicKey: threadId)
}
@ -461,15 +461,20 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat
name: updatedName
)
}
.done(on: DispatchQueue.main) { [weak self] in
self?.dismiss(animated: true, completion: nil) // Dismiss the loader
popToConversationVC(self)
}
.catch(on: DispatchQueue.main) { [weak self] error in
self?.dismiss(animated: true, completion: nil) // Dismiss the loader
self?.showError(title: "GROUP_UPDATE_ERROR_TITLE".localized(), message: error.localizedDescription)
}
.retainUntilComplete()
.sinkUntilComplete(
receiveCompletion: { [weak self] result in
self?.dismiss(animated: true, completion: nil) // Dismiss the loader
switch result {
case .finished: popToConversationVC(self)
case .failure(let error):
self?.showError(
title: "GROUP_UPDATE_ERROR_TITLE".localized(),
message: error.localizedDescription
)
}
}
)
}
}

View File

@ -205,15 +205,17 @@ final class NewClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate
cell.update(
with: SessionCell.Info(
id: profile,
leftAccessory: .profile(profile.id, profile),
position: Position.with(indexPath.row, count: data[indexPath.section].elements.count),
leftAccessory: .profile(id: profile.id, profile: profile),
title: profile.displayName(),
rightAccessory: .radio(isSelected: { [weak self] in
self?.selectedContacts.contains(profile.id) == true
}),
accessibilityIdentifier: "Contact"
),
style: .edgeToEdge,
position: Position.with(indexPath.row, count: data[indexPath.section].elements.count)
styling: SessionCell.StyleInfo(backgroundStyle: .edgeToEdge),
accessibility: SessionCell.Accessibility(
identifier: "Contact"
)
)
)
return cell

View File

@ -1872,7 +1872,7 @@ extension ConversationVC:
deleteRemotely(
from: self,
request: SnodeAPI
.deleteMessage(
.deleteMessages(
publicKey: threadId,
serverHashes: [serverHash]
)
@ -2328,10 +2328,11 @@ extension ConversationVC {
)
.save(db)
// Send a sync message with the details of the contact
// Update the config with the approved contact
try MessageSender
.syncConfiguration(db, forceSyncNow: true)
.retainUntilComplete()
.sinkUntilComplete()
},
completion: { _, _ in updateNavigationBackStack() }
)
@ -2347,79 +2348,31 @@ extension ConversationVC {
}
@objc func deleteMessageRequest() {
guard self.viewModel.threadData.threadVariant == .contact else { return }
let threadId: String = self.viewModel.threadData.threadId
let alertVC: UIAlertController = UIAlertController(
title: "MESSAGE_REQUESTS_DELETE_CONFIRMATION_ACTON".localized(),
message: nil,
preferredStyle: .actionSheet
)
alertVC.addAction(UIAlertAction(title: "TXT_DELETE_TITLE".localized(), style: .destructive) { _ in
// Delete the request
Storage.shared.writeAsync(
updates: { db in
_ = try SessionThread
.filter(id: threadId)
.deleteAll(db)
},
completion: { db, _ in
DispatchQueue.main.async { [weak self] in
self?.navigationController?.popViewController(animated: true)
}
}
)
})
alertVC.addAction(UIAlertAction(title: "TXT_CANCEL_TITLE".localized(), style: .cancel, handler: nil))
self.present(alertVC, animated: true, completion: nil)
MessageRequestsViewModel.deleteMessageRequest(
threadId: self.viewModel.threadData.threadId,
threadVariant: self.viewModel.threadData.threadVariant,
viewController: self
) { [weak self] in
self?.stopObservingChanges()
DispatchQueue.main.async {
self?.navigationController?.popViewController(animated: true)
}
}
}
@objc func block() {
guard self.viewModel.threadData.threadVariant == .contact else { return }
let threadId: String = self.viewModel.threadData.threadId
let alertVC: UIAlertController = UIAlertController(
title: "MESSAGE_REQUESTS_BLOCK_CONFIRMATION_ACTON".localized(),
message: nil,
preferredStyle: .actionSheet
)
alertVC.addAction(UIAlertAction(title: "BLOCK_LIST_BLOCK_BUTTON".localized(), style: .destructive) { _ in
// Delete the request
Storage.shared.writeAsync(
updates: { db in
// Update the contact
_ = try Contact
.fetchOrCreate(db, id: threadId)
.with(
isApproved: false,
isBlocked: true,
// Note: We set this to true so the current user will be able to send a
// message to the person who originally sent them the message request in
// the future if they unblock them
didApproveMe: true
)
.saved(db)
_ = try SessionThread
.filter(id: threadId)
.deleteAll(db)
try MessageSender
.syncConfiguration(db, forceSyncNow: true)
.retainUntilComplete()
},
completion: { db, _ in
DispatchQueue.main.async { [weak self] in
self?.navigationController?.popViewController(animated: true)
}
}
)
})
alertVC.addAction(UIAlertAction(title: "TXT_CANCEL_TITLE".localized(), style: .cancel, handler: nil))
self.present(alertVC, animated: true, completion: nil)
@objc func blockMessageRequest() {
MessageRequestsViewModel.blockMessageRequest(
threadId: self.viewModel.threadData.threadId,
threadVariant: self.viewModel.threadData.threadVariant,
viewController: self
) { [weak self] in
self?.stopObservingChanges()
DispatchQueue.main.async {
self?.navigationController?.popViewController(animated: true)
}
}
}
}

View File

@ -27,10 +27,6 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
var focusedInteractionId: Int64?
var shouldHighlightNextScrollToInteraction: Bool = false
var scrollButtonBottomConstraint: NSLayoutConstraint?
var scrollButtonMessageRequestsBottomConstraint: NSLayoutConstraint?
var scrollButtonPendingMessageRequestInfoBottomConstraint: NSLayoutConstraint?
var messageRequestsViewBotomConstraint: NSLayoutConstraint?
// Search
var isShowingSearchUI = false
@ -40,8 +36,6 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
var audioRecorder: AVAudioRecorder?
var audioTimer: Timer?
private var searchBarWidth: NSLayoutConstraint?
// Context menu
var contextMenuWindow: ContextMenuWindow?
var contextMenuVC: ContextMenuVC?
@ -129,6 +123,12 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
// MARK: - UI
var scrollButtonBottomConstraint: NSLayoutConstraint?
var scrollButtonMessageRequestsBottomConstraint: NSLayoutConstraint?
var scrollButtonPendingMessageRequestInfoBottomConstraint: NSLayoutConstraint?
var messageRequestsViewBotomConstraint: NSLayoutConstraint?
var messageRequestDescriptionLabelBottomConstraint: NSLayoutConstraint?
lazy var titleView: ConversationTitleView = {
let result: ConversationTitleView = ConversationTitleView()
let tapGestureRecognizer = UITapGestureRecognizer(
@ -221,11 +221,22 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
}()
lazy var scrollButton: ScrollToBottomButton = ScrollToBottomButton(delegate: self)
lazy var messageRequestView: UIView = {
lazy var messageRequestBackgroundView: UIView = {
let result: UIView = UIView()
result.translatesAutoresizingMaskIntoConstraints = false
result.themeBackgroundColor = .backgroundPrimary
result.isHidden = messageRequestStackView.isHidden
return result
}()
lazy var messageRequestStackView: UIStackView = {
let result: UIStackView = UIStackView()
result.translatesAutoresizingMaskIntoConstraints = false
result.axis = .vertical
result.alignment = .fill
result.distribution = .fill
result.isHidden = (
self.viewModel.threadData.threadIsMessageRequest == false ||
self.viewModel.threadData.threadRequiresApproval == true
@ -233,18 +244,40 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
return result
}()
private lazy var messageRequestDescriptionContainerView: UIView = {
let result: UIView = UIView()
result.translatesAutoresizingMaskIntoConstraints = false
return result
}()
private let messageRequestDescriptionLabel: UILabel = {
private lazy var messageRequestDescriptionLabel: UILabel = {
let result: UILabel = UILabel()
result.translatesAutoresizingMaskIntoConstraints = false
result.setContentCompressionResistancePriority(.required, for: .vertical)
result.font = UIFont.systemFont(ofSize: 12)
result.text = "MESSAGE_REQUESTS_INFO".localized()
result.text = (self.viewModel.threadData.threadRequiresApproval == false ?
"MESSAGE_REQUESTS_INFO".localized() :
"MESSAGE_REQUEST_PENDING_APPROVAL_INFO".localized()
)
result.themeTextColor = .textSecondary
result.textAlignment = .center
result.numberOfLines = 0
return result
}()
private lazy var messageRequestActionStackView: UIStackView = {
let result: UIStackView = UIStackView()
result.translatesAutoresizingMaskIntoConstraints = false
result.axis = .horizontal
result.alignment = .fill
result.distribution = .fill
result.spacing = (UIDevice.current.isIPad ? Values.iPadButtonSpacing : 20)
return result
}()
private lazy var messageRequestAcceptButton: UIButton = {
let result: SessionButton = SessionButton(style: .bordered, size: .medium)
@ -276,27 +309,11 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
result.titleLabel?.font = UIFont.boldSystemFont(ofSize: 16)
result.setTitle("TXT_BLOCK_USER_TITLE".localized(), for: .normal)
result.setThemeTitleColor(.danger, for: .normal)
result.addTarget(self, action: #selector(block), for: .touchUpInside)
result.addTarget(self, action: #selector(blockMessageRequest), for: .touchUpInside)
result.isHidden = (self.viewModel.threadData.threadVariant != .contact)
return result
}()
private lazy var pendingMessageRequestExplanationLabel: UILabel = {
let result: UILabel = UILabel()
result.translatesAutoresizingMaskIntoConstraints = false
result.setContentCompressionResistancePriority(.required, for: .vertical)
result.font = UIFont.systemFont(ofSize: 12)
result.text = "MESSAGE_REQUEST_PENDING_APPROVAL_INFO".localized()
result.themeTextColor = .textSecondary
result.textAlignment = .center
result.numberOfLines = 0
result.isHidden = (
!self.messageRequestStackView.isHidden ||
self.viewModel.threadData.threadRequiresApproval == false
)
return result
}()
// MARK: - Settings
@ -352,46 +369,32 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
view.addSubview(scrollButton)
view.addSubview(messageRequestBackgroundView)
view.addSubview(messageRequestStackView)
view.addSubview(pendingMessageRequestExplanationLabel)
messageRequestView.addSubview(messageRequestBlockButton)
messageRequestView.addSubview(messageRequestDescriptionLabel)
messageRequestView.addSubview(messageRequestAcceptButton)
messageRequestView.addSubview(messageRequestDeleteButton)
messageRequestStackView.addArrangedSubview(messageRequestBlockButton)
messageRequestStackView.addArrangedSubview(messageRequestDescriptionContainerView)
messageRequestStackView.addArrangedSubview(messageRequestActionStackView)
messageRequestDescriptionContainerView.addSubview(messageRequestDescriptionLabel)
messageRequestActionStackView.addArrangedSubview(messageRequestAcceptButton)
messageRequestActionStackView.addArrangedSubview(messageRequestDeleteButton)
scrollButton.pin(.right, to: .right, of: view, withInset: -20)
messageRequestView.pin(.left, to: .left, of: view)
messageRequestView.pin(.right, to: .right, of: view)
self.messageRequestsViewBotomConstraint = messageRequestView.pin(.bottom, to: .bottom, of: view, withInset: -16)
scrollButton.pin(.trailing, to: .trailing, of: view, withInset: -20)
messageRequestStackView.pin(.leading, to: .leading, of: view, withInset: 16)
messageRequestStackView.pin(.trailing, to: .trailing, of: view, withInset: -16)
self.messageRequestsViewBotomConstraint = messageRequestStackView.pin(.bottom, to: .bottom, of: view, withInset: -16)
self.scrollButtonBottomConstraint = scrollButton.pin(.bottom, to: .bottom, of: view, withInset: -16)
self.scrollButtonBottomConstraint?.isActive = false // Note: Need to disable this to avoid a conflict with the other bottom constraint
self.scrollButtonMessageRequestsBottomConstraint = scrollButton.pin(.bottom, to: .top, of: messageRequestStackView)
self.scrollButtonPendingMessageRequestInfoBottomConstraint = scrollButton.pin(.bottom, to: .top, of: pendingMessageRequestExplanationLabel, withInset: -16)
self.scrollButtonMessageRequestsBottomConstraint = scrollButton.pin(.bottom, to: .top, of: messageRequestStackView, withInset: -4)
messageRequestBlockButton.pin(.top, to: .top, of: messageRequestView, withInset: 10)
messageRequestBlockButton.center(.horizontal, in: messageRequestView)
messageRequestDescriptionLabel.pin(.top, to: .bottom, of: messageRequestBlockButton, withInset: 5)
messageRequestDescriptionLabel.pin(.left, to: .left, of: messageRequestView, withInset: 40)
messageRequestDescriptionLabel.pin(.right, to: .right, of: messageRequestView, withInset: -40)
messageRequestAcceptButton.pin(.top, to: .bottom, of: messageRequestDescriptionLabel, withInset: 20)
messageRequestAcceptButton.pin(.left, to: .left, of: messageRequestView, withInset: 20)
messageRequestAcceptButton.pin(.bottom, to: .bottom, of: messageRequestView)
messageRequestDeleteButton.pin(.top, to: .bottom, of: messageRequestDescriptionLabel, withInset: 20)
messageRequestDeleteButton.pin(.left, to: .right, of: messageRequestAcceptButton, withInset: UIDevice.current.isIPad ? Values.iPadButtonSpacing : 20)
messageRequestDeleteButton.pin(.right, to: .right, of: messageRequestView, withInset: -20)
messageRequestDeleteButton.pin(.bottom, to: .bottom, of: messageRequestView)
messageRequestDescriptionLabel.pin(.top, to: .top, of: messageRequestDescriptionContainerView, withInset: 4)
messageRequestDescriptionLabel.pin(.leading, to: .leading, of: messageRequestDescriptionContainerView, withInset: 20)
messageRequestDescriptionLabel.pin(.trailing, to: .trailing, of: messageRequestDescriptionContainerView, withInset: -20)
self.messageRequestDescriptionLabelBottomConstraint = messageRequestDescriptionLabel.pin(.bottom, to: .bottom, of: messageRequestDescriptionContainerView, withInset: -20)
messageRequestActionStackView.pin(.top, to: .bottom, of: messageRequestDescriptionContainerView)
messageRequestDeleteButton.set(.width, to: .width, of: messageRequestAcceptButton)
messageRequestBackgroundView.pin(.top, to: .top, of: messageRequestStackView)
messageRequestBackgroundView.pin(.leading, to: .leading, of: view)
messageRequestBackgroundView.pin(.trailing, to: .trailing, of: view)
messageRequestBackgroundView.pin(.bottom, to: .bottom, of: view)
pendingMessageRequestExplanationLabel.pin(.left, to: .left, of: messageRequestStackView, withInset: 40)
pendingMessageRequestExplanationLabel.pin(.right, to: .right, of: messageRequestStackView, withInset: -40)
pendingMessageRequestExplanationLabel.pin(.bottom, to: .bottom, of: messageRequestStackView, withInset: -16)
// Unread count view
view.addSubview(unreadCountView)
@ -505,12 +508,6 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
stopObservingChanges()
}
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
searchBarWidth?.constant = size.width - 32
tableView.reloadData()
}
// MARK: - Updating
private func startObservingChanges(didReturnFromBackground: Bool = false) {
@ -571,7 +568,7 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
)
}
private func stopObservingChanges() {
func stopObservingChanges() {
// Stop observing database changes
dataChangeObservable?.cancel()
self.viewModel.onInteractionChange = nil
@ -619,6 +616,7 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
if
initialLoad ||
viewModel.threadData.threadVariant != updatedThreadData.threadVariant ||
viewModel.threadData.threadRequiresApproval != updatedThreadData.threadRequiresApproval ||
viewModel.threadData.threadIsMessageRequest != updatedThreadData.threadIsMessageRequest ||
viewModel.threadData.profile != updatedThreadData.profile
@ -628,47 +626,33 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
let messageRequestsViewWasVisible: Bool = (
messageRequestStackView.isHidden == false
)
let pendingMessageRequestInfoWasVisible: Bool = (
pendingMessageRequestExplanationLabel.isHidden == false
)
UIView.animate(withDuration: 0.3) { [weak self] in
self?.messageRequestView.isHidden = (
updatedThreadData.threadIsMessageRequest == false ||
self?.messageRequestBlockButton.isHidden = (
self?.viewModel.threadData.threadVariant != .contact ||
updatedThreadData.threadRequiresApproval == true
)
self?.messageRequestBackgroundView.isHidden = (self?.messageRequestStackView.isHidden == true)
self?.pendingMessageRequestExplanationLabel.isHidden = (
self?.messageRequestStackView.isHidden == false ||
self?.messageRequestActionStackView.isHidden = (
updatedThreadData.threadRequiresApproval == true
)
self?.messageRequestStackView.isHidden = (
updatedThreadData.threadIsMessageRequest == false &&
updatedThreadData.threadRequiresApproval == false
)
self?.messageRequestBackgroundView.isHidden = (self?.messageRequestStackView.isHidden == true)
self?.messageRequestDescriptionLabelBottomConstraint?.constant = (updatedThreadData.threadRequiresApproval == true ? -4 : -20)
self?.scrollButtonMessageRequestsBottomConstraint?.isActive = (
self?.messageRequestStackView.isHidden == false
)
self?.scrollButtonPendingMessageRequestInfoBottomConstraint?.isActive = (
self?.scrollButtonPendingMessageRequestInfoBottomConstraint?.isActive == false &&
self?.pendingMessageRequestExplanationLabel.isHidden == false
)
self?.scrollButtonBottomConstraint?.isActive = (
self?.scrollButtonMessageRequestsBottomConstraint?.isActive == false &&
self?.scrollButtonPendingMessageRequestInfoBottomConstraint?.isActive == false
self?.scrollButtonMessageRequestsBottomConstraint?.isActive == false
)
// Update the table content inset and offset to account for
// the dissapearance of the messageRequestsView
if messageRequestsViewWasVisible {
let messageRequestsOffset: CGFloat = ((self?.messageRequestView.bounds.height ?? 0) + 16)
let oldContentInset: UIEdgeInsets = (self?.tableView.contentInset ?? UIEdgeInsets.zero)
self?.tableView.contentInset = UIEdgeInsets(
top: 0,
leading: 0,
bottom: max(oldContentInset.bottom - messageRequestsOffset, 0),
trailing: 0
)
}
else if pendingMessageRequestInfoWasVisible {
let messageRequestsOffset: CGFloat = ((self?.pendingMessageRequestExplanationLabel.bounds.height ?? 0) + (16 * 2))
if messageRequestsViewWasVisible != (self?.messageRequestStackView.isHidden == false) {
let messageRequestsOffset: CGFloat = ((self?.messageRequestStackView.bounds.height ?? 0) + 12)
let oldContentInset: UIEdgeInsets = (self?.tableView.contentInset ?? UIEdgeInsets.zero)
self?.tableView.contentInset = UIEdgeInsets(
top: 0,
@ -1103,9 +1087,12 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
profilePictureView.size = Values.verySmallProfilePictureSize
profilePictureView.update(
publicKey: threadData.threadId, // Contact thread uses the contactId
threadVariant: threadData.threadVariant,
customImageData: nil,
profile: threadData.profile,
threadVariant: threadData.threadVariant
additionalProfile: nil
)
profilePictureView.set(.width, to: (44 - 16)) // Width of the standard back button
profilePictureView.set(.height, to: Values.verySmallProfilePictureSize)
@ -1159,7 +1146,7 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
// needed for proper calculations, so force an initial layout if it doesn't have a size)
var hasDoneLayout: Bool = true
if messageRequestView.bounds.height <= CGFloat.leastNonzeroMagnitude {
if messageRequestStackView.bounds.height <= CGFloat.leastNonzeroMagnitude {
hasDoneLayout = false
UIView.performWithoutAnimation {
@ -1168,19 +1155,18 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
}
let keyboardTop = (UIScreen.main.bounds.height - keyboardRect.minY)
let messageRequestsOffset: CGFloat = (messageRequestStackView.isHidden ? 0 : messageRequestStackView.bounds.height + 16)
let pendingMessageRequestsOffset: CGFloat = (pendingMessageRequestExplanationLabel.isHidden ? 0 : (pendingMessageRequestExplanationLabel.bounds.height + (16 * 2)))
let messageRequestsOffset: CGFloat = (messageRequestStackView.isHidden ? 0 : messageRequestStackView.bounds.height + 12)
let oldContentInset: UIEdgeInsets = tableView.contentInset
let newContentInset: UIEdgeInsets = UIEdgeInsets(
top: 0,
leading: 0,
bottom: (Values.mediumSpacing + keyboardTop + messageRequestsOffset + pendingMessageRequestsOffset),
bottom: (Values.mediumSpacing + keyboardTop + messageRequestsOffset),
trailing: 0
)
let newContentOffsetY: CGFloat = (tableView.contentOffset.y + (newContentInset.bottom - oldContentInset.bottom))
let changes = { [weak self] in
self?.scrollButtonBottomConstraint?.constant = -(keyboardTop + 16)
self?.messageRequestsViewBotomConstraint?.constant = -(keyboardTop + 16)
self?.scrollButtonBottomConstraint?.constant = -(keyboardTop + 12)
self?.messageRequestsViewBotomConstraint?.constant = -(keyboardTop + 12)
self?.tableView.contentInset = newContentInset
self?.tableView.contentOffset.y = newContentOffsetY
@ -1226,8 +1212,8 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
delay: 0,
options: options,
animations: { [weak self] in
self?.scrollButtonBottomConstraint?.constant = -(keyboardTop + 16)
self?.messageRequestsViewBotomConstraint?.constant = -(keyboardTop + 16)
self?.scrollButtonBottomConstraint?.constant = -(keyboardTop + 12)
self?.messageRequestsViewBotomConstraint?.constant = -(keyboardTop + 12)
let scrollButtonOpacity: CGFloat = (self?.getScrollButtonOpacity() ?? 0)
self?.scrollButton.alpha = scrollButtonOpacity
@ -1536,7 +1522,7 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
searchBar.sizeToFit()
searchBar.layoutMargins = UIEdgeInsets.zero
searchBarContainer.set(.height, to: 44)
searchBarWidth = searchBarContainer.set(.width, to: UIScreen.main.bounds.width - 32)
searchBarContainer.set(.width, to: UIScreen.main.bounds.width - 32)
searchBarContainer.addSubview(searchBar)
navigationItem.titleView = searchBarContainer
@ -1676,6 +1662,11 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
animated: (self.didFinishInitialLayout && isAnimated)
)
// Need to explicitly call 'scrollViewDidScroll' here as it won't get triggered
// by 'scrollToRow' if a scroll doesn't occur (eg. if there is less than 1 screen
// of messages)
self.scrollViewDidScroll(self.tableView)
// If we haven't finished the initial layout then we want to delay the highlight slightly
// so it doesn't look buggy with the push transition
if highlight {

View File

@ -199,8 +199,10 @@ private extension MentionSelectionView {
displayNameLabel.text = profile.displayName(for: threadVariant)
profilePictureView.update(
publicKey: profile.id,
threadVariant: .contact,
customImageData: nil,
profile: profile,
threadVariant: threadVariant
additionalProfile: nil
)
moderatorIconImageView.isHidden = !isUserModeratorOrAdmin
separator.isHidden = isLast

View File

@ -46,7 +46,7 @@ final class DocumentView: UIView {
// Size label
let sizeLabel = UILabel()
sizeLabel.font = .systemFont(ofSize: Values.verySmallFontSize)
sizeLabel.text = OWSFormat.formatFileSize(UInt(attachment.byteCount))
sizeLabel.text = Format.fileSize(attachment.byteCount)
sizeLabel.themeTextColor = textColor
sizeLabel.lineBreakMode = .byTruncatingTail

View File

@ -111,11 +111,10 @@ public class MediaAlbumView: UIStackView {
tintView.autoPinEdgesToSuperviewEdges()
let moreCount = max(1, items.count - MediaAlbumView.kMaxItems)
let moreCountText = OWSFormat.formatInt(Int32(moreCount))
let moreText = String(
// Format for the 'more items' indicator for media galleries. Embeds {{the number of additional items}}.
format: "MEDIA_GALLERY_MORE_ITEMS_FORMAT".localized(),
moreCountText
"\(moreCount)"
)
let moreLabel: UILabel = UILabel()
moreLabel.font = .systemFont(ofSize: 24)

View File

@ -289,8 +289,10 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
profilePictureView.isHidden = (!cellViewModel.shouldShowProfile || cellViewModel.profile == nil)
profilePictureView.update(
publicKey: cellViewModel.authorId,
threadVariant: cellViewModel.threadVariant,
customImageData: nil,
profile: cellViewModel.profile,
threadVariant: cellViewModel.threadVariant
additionalProfile: nil
)
moderatorIconImageView.isHidden = (!cellViewModel.isSenderOpenGroupModerator || !cellViewModel.shouldShowProfile)

View File

@ -85,10 +85,7 @@ class ThreadDisappearingMessagesViewModel: SessionTableViewModel<ThreadDisappear
override var title: String { "DISAPPEARING_MESSAGES".localized() }
private var _settingsData: [SectionModel] = []
public override var settingsData: [SectionModel] { _settingsData }
public override var observableSettingsData: ObservableData { _observableSettingsData }
public override var observableTableData: ObservableData { _observableTableData }
/// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise
/// performance https://github.com/groue/GRDB.swift#valueobservation-performance
@ -97,7 +94,7 @@ class ThreadDisappearingMessagesViewModel: SessionTableViewModel<ThreadDisappear
/// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own
/// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`)
/// just in case the database has changed between the two reads - unfortunately it doesn't look like there is a way to prevent this
private lazy var _observableSettingsData: ObservableData = ValueObservation
private lazy var _observableTableData: ObservableData = ValueObservation
.trackingConstantRegion { [weak self, config] db -> [SectionModel] in
return [
SectionModel(
@ -131,12 +128,9 @@ class ThreadDisappearingMessagesViewModel: SessionTableViewModel<ThreadDisappear
}
.removeDuplicates()
.publisher(in: dependencies.storage, scheduling: dependencies.scheduler)
.mapToSessionTableViewData(for: self)
// MARK: - Functions
public override func updateSettings(_ updatedSettings: [SectionModel]) {
self._settingsData = updatedSettings
}
private func saveChanges() {
let threadId: String = self.threadId

View File

@ -29,7 +29,10 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
}
public enum Setting: Differentiable {
case threadInfo
case avatar
case nickname
case sessionId
case copyThreadId
case allMedia
case searchConversation
@ -170,10 +173,7 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
}
}
private var _settingsData: [SectionModel] = []
public override var settingsData: [SectionModel] { _settingsData }
public override var observableSettingsData: ObservableData { _observableSettingsData }
public override var observableTableData: ObservableData { _observableTableData }
/// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise
/// performance https://github.com/groue/GRDB.swift#valueobservation-performance
@ -182,7 +182,7 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
/// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own
/// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`)
/// just in case the database has changed between the two reads - unfortunately it doesn't look like there is a way to prevent this
private lazy var _observableSettingsData: ObservableData = ValueObservation
private lazy var _observableTableData: ObservableData = ValueObservation
.trackingConstantRegion { [weak self, dependencies, threadId = self.threadId, threadVariant = self.threadVariant] db -> [SectionModel] in
let userPublicKey: String = getUserHexEncodedPublicKey(db, dependencies: dependencies)
let maybeThreadViewModel: SessionThreadViewModel? = try SessionThreadViewModel
@ -207,25 +207,88 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
threadVariant == .closedGroup &&
threadViewModel.currentUserIsClosedGroupMember == true
)
let editIcon: UIImage? = UIImage(named: "icon_edit")
return [
SectionModel(
model: .conversationInfo,
elements: [
SessionCell.Info(
id: .threadInfo,
leftAccessory: .threadInfo(
threadViewModel: threadViewModel,
avatarTapped: { [weak self] in
self?.updateProfilePicture(threadViewModel: threadViewModel)
},
titleTapped: { [weak self] in self?.setIsEditing(true) },
titleChanged: { [weak self] text in self?.editedDisplayName = text }
id: .avatar,
accessory: .profile(
id: threadViewModel.id,
size: .extraLarge,
threadVariant: threadVariant,
customImageData: threadViewModel.openGroupProfilePictureData,
profile: threadViewModel.profile,
additionalProfile: threadViewModel.additionalProfile,
cornerIcon: nil,
accessibility: nil
),
title: threadViewModel.displayName,
shouldHaveBackground: false
styling: SessionCell.StyleInfo(
alignment: .centerHugging,
customPadding: SessionCell.Padding(bottom: Values.smallSpacing),
backgroundStyle: .noBackground
),
onTap: { self?.viewProfilePicture(threadViewModel: threadViewModel) }
),
SessionCell.Info(
id: .nickname,
leftAccessory: (threadVariant != .contact ? nil :
.icon(
editIcon?.withRenderingMode(.alwaysTemplate),
size: .fit,
customTint: .textSecondary
)
),
title: SessionCell.TextInfo(
threadViewModel.displayName,
font: .titleLarge,
alignment: .center,
editingPlaceholder: "CONTACT_NICKNAME_PLACEHOLDER".localized(),
interaction: (threadVariant == .contact ? .editable : .none)
),
styling: SessionCell.StyleInfo(
alignment: .centerHugging,
customPadding: SessionCell.Padding(
top: Values.smallSpacing,
trailing: (threadVariant != .contact ?
nil :
-(((editIcon?.size.width ?? 0) + (Values.smallSpacing * 2)) / 2)
),
bottom: (threadVariant != .contact ?
nil :
Values.smallSpacing
),
interItem: 0
),
backgroundStyle: .noBackground
),
onTap: {
self?.textChanged(self?.oldDisplayName, for: .nickname)
self?.setIsEditing(true)
}
),
(threadVariant != .contact ? nil :
SessionCell.Info(
id: .sessionId,
subtitle: SessionCell.TextInfo(
threadViewModel.id,
font: .monoSmall,
alignment: .center,
interaction: .copy
),
styling: SessionCell.StyleInfo(
customPadding: SessionCell.Padding(
top: Values.smallSpacing,
bottom: Values.largeSpacing
),
backgroundStyle: .noBackground
)
)
)
]
].compactMap { $0 }
),
SectionModel(
model: .content,
@ -241,27 +304,29 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
"COPY_GROUP_URL".localized() :
"vc_conversation_settings_copy_session_id_button_title".localized()
),
accessibilityIdentifier: "\(ThreadSettingsViewModel.self).copy_thread_id",
accessibilityLabel: "Copy Session ID",
accessibility: SessionCell.Accessibility(
identifier: "\(ThreadSettingsViewModel.self).copy_thread_id",
label: "Copy Session ID"
),
onTap: {
switch threadVariant {
case .contact, .closedGroup:
UIPasteboard.general.string = threadId
case .openGroup:
guard
let server: String = threadViewModel.openGroupServer,
let roomToken: String = threadViewModel.openGroupRoomToken,
let publicKey: String = threadViewModel.openGroupPublicKey
else { return }
UIPasteboard.general.string = OpenGroup.urlFor(
server: server,
roomToken: roomToken,
publicKey: publicKey
)
}
self?.showToast(
text: "copied".localized(),
backgroundColor: .backgroundSecondary
@ -269,7 +334,7 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
}
)
),
SessionCell.Info(
id: .allMedia,
leftAccessory: .icon(
@ -277,8 +342,10 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
.withRenderingMode(.alwaysTemplate)
),
title: MediaStrings.allMedia,
accessibilityIdentifier: "\(ThreadSettingsViewModel.self).all_media",
accessibilityLabel: "All media",
accessibility: SessionCell.Accessibility(
identifier: "\(ThreadSettingsViewModel.self).all_media",
label: "All media"
),
onTap: { [weak self] in
self?.transitionToScreen(
MediaGalleryViewModel.createAllMediaViewController(
@ -289,7 +356,7 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
)
}
),
SessionCell.Info(
id: .searchConversation,
leftAccessory: .icon(
@ -297,13 +364,15 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
.withRenderingMode(.alwaysTemplate)
),
title: "CONVERSATION_SETTINGS_SEARCH".localized(),
accessibilityIdentifier: "\(ThreadSettingsViewModel.self).search",
accessibilityLabel: "Search",
accessibility: SessionCell.Accessibility(
identifier: "\(ThreadSettingsViewModel.self).search",
label: "Search"
),
onTap: { [weak self] in
self?.didTriggerSearch()
}
),
(threadVariant != .openGroup ? nil :
SessionCell.Info(
id: .addToOpenGroup,
@ -312,7 +381,9 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
.withRenderingMode(.alwaysTemplate)
),
title: "vc_conversation_settings_invite_button_title".localized(),
accessibilityIdentifier: "\(ThreadSettingsViewModel.self).add_to_open_group",
accessibility: SessionCell.Accessibility(
identifier: "\(ThreadSettingsViewModel.self).add_to_open_group"
),
onTap: { [weak self] in
self?.transitionToScreen(
UserSelectionVC(
@ -328,7 +399,7 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
}
)
),
(threadVariant == .openGroup || threadViewModel.threadIsBlocked == true ? nil :
SessionCell.Info(
id: .disappearingMessages,
@ -338,7 +409,10 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
"ic_timer" :
"ic_timer_disabled"
)
)?.withRenderingMode(.alwaysTemplate)
)?.withRenderingMode(.alwaysTemplate),
accessibility: SessionCell.Accessibility(
label: "Timer icon"
)
),
title: "DISAPPEARING_MESSAGES".localized(),
subtitle: (disappearingMessagesConfig.isEnabled ?
@ -348,9 +422,10 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
) :
"DISAPPEARING_MESSAGES_SUBTITLE_OFF".localized()
),
accessibilityIdentifier: "\(ThreadSettingsViewModel.self).disappearing_messages",
accessibilityLabel: "Disappearing messages",
leftAccessoryAccessibilityLabel: "Timer icon",
accessibility: SessionCell.Accessibility(
identifier: "\(ThreadSettingsViewModel.self).disappearing_messages",
label: "Disappearing messages"
),
onTap: { [weak self] in
self?.transitionToScreen(
SessionTableViewController(
@ -363,7 +438,7 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
}
)
),
(!currentUserIsClosedGroupMember ? nil :
SessionCell.Info(
id: .editGroup,
@ -372,8 +447,10 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
.withRenderingMode(.alwaysTemplate)
),
title: "EDIT_GROUP_ACTION".localized(),
accessibilityIdentifier: "Edit group",
accessibilityLabel: "Edit group",
accessibility: SessionCell.Accessibility(
identifier: "Edit group",
label: "Edit group"
),
onTap: { [weak self] in
self?.transitionToScreen(EditClosedGroupVC(threadId: threadId))
}
@ -388,8 +465,10 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
.withRenderingMode(.alwaysTemplate)
),
title: "LEAVE_GROUP_ACTION".localized(),
accessibilityIdentifier: "Leave group",
accessibilityLabel: "Leave group",
accessibility: SessionCell.Accessibility(
identifier: "Leave group",
label: "Leave group"
),
confirmationInfo: ConfirmationModal.Info(
title: "CONFIRM_LEAVE_GROUP_TITLE".localized(),
explanation: (currentUserIsClosedGroupMember ?
@ -401,9 +480,11 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
cancelStyle: .alert_text
),
onTap: { [weak self] in
dependencies.storage.writeAsync { db in
try MessageSender.leave(db, groupPublicKey: threadId)
}
dependencies.storage
.writePublisherFlatMap { db in
MessageSender.leave(db, groupPublicKey: threadId)
}
.sinkUntilComplete()
}
)
),
@ -445,8 +526,10 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
threadViewModel.threadVariant != .closedGroup ||
currentUserIsClosedGroupMember
),
accessibilityIdentifier: "Mentions only notification setting",
accessibilityLabel: "Mentions only",
accessibility: SessionCell.Accessibility(
identifier: "Mentions only notification setting",
label: "Mentions only"
),
onTap: {
let newValue: Bool = !(threadViewModel.threadOnlyNotifyForMentions == true)
@ -478,8 +561,10 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
threadViewModel.threadVariant != .closedGroup ||
currentUserIsClosedGroupMember
),
accessibilityIdentifier: "\(ThreadSettingsViewModel.self).mute",
accessibilityLabel: "Mute notifications",
accessibility: SessionCell.Accessibility(
identifier: "\(ThreadSettingsViewModel.self).mute",
label: "Mute notifications"
),
onTap: {
dependencies.storage.writeAsync { db in
let currentValue: TimeInterval? = try SessionThread
@ -515,8 +600,10 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
rightAccessory: .toggle(
.boolValue(threadViewModel.threadIsBlocked == true)
),
accessibilityIdentifier: "\(ThreadSettingsViewModel.self).block",
accessibilityLabel: "Block",
accessibility: SessionCell.Accessibility(
identifier: "\(ThreadSettingsViewModel.self).block",
label: "Block"
),
confirmationInfo: ConfirmationModal.Info(
title: {
guard threadViewModel.threadIsBlocked == true else {
@ -561,14 +648,11 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
}
.removeDuplicates()
.publisher(in: dependencies.storage, scheduling: dependencies.scheduler)
.mapToSessionTableViewData(for: self)
// MARK: - Functions
public override func updateSettings(_ updatedSettings: [SectionModel]) {
self._settingsData = updatedSettings
}
private func updateProfilePicture(threadViewModel: SessionThreadViewModel) {
private func viewProfilePicture(threadViewModel: SessionThreadViewModel) {
guard
threadViewModel.threadVariant == .contact,
let profile: Profile = threadViewModel.profile,

View File

@ -431,7 +431,8 @@ extension ReactionListSheet: UITableViewDelegate, UITableViewDataSource {
cell.update(
with: SessionCell.Info(
id: cellViewModel,
leftAccessory: .profile(authorId, cellViewModel.profile),
position: Position.with(indexPath.row, count: self.selectedReactionUserList.count),
leftAccessory: .profile(id: authorId, profile: cellViewModel.profile),
title: (
cellViewModel.profile?.displayName() ??
Profile.truncated(
@ -446,10 +447,9 @@ extension ReactionListSheet: UITableViewDelegate, UITableViewDataSource {
size: .fit
)
),
styling: SessionCell.StyleInfo(backgroundStyle: .edgeToEdge),
isEnabled: (authorId == self.messageViewModel.currentUserPublicKey)
),
style: .edgeToEdge,
position: Position.with(indexPath.row, count: self.selectedReactionUserList.count)
)
)
return cell

View File

@ -472,8 +472,10 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
profilePictureView.size = profilePictureSize
profilePictureView.update(
publicKey: getUserHexEncodedPublicKey(),
threadVariant: .contact,
customImageData: nil,
profile: Profile.fetchOrCreateCurrentUser(),
threadVariant: .contact
additionalProfile: nil
)
profilePictureView.set(.width, to: profilePictureSize)
profilePictureView.set(.height, to: profilePictureSize)

View File

@ -142,13 +142,13 @@ final class NewConversationVC: BaseVC, ThemedNavigation, UITableViewDelegate, UI
cell.update(
with: SessionCell.Info(
id: profile,
leftAccessory: .profile(profile.id, profile),
title: profile.displayName()
),
style: .edgeToEdge,
position: Position.with(
indexPath.row,
count: newConversationViewModel.sectionData[indexPath.section].contacts.count
position: Position.with(
indexPath.row,
count: newConversationViewModel.sectionData[indexPath.section].contacts.count
),
leftAccessory: .profile(id: profile.id, profile: profile),
title: profile.displayName(),
styling: SessionCell.StyleInfo(backgroundStyle: .edgeToEdge)
)
)

View File

@ -497,7 +497,7 @@ class DocumentCell: UITableViewCell {
func update(with item: MediaGalleryViewModel.Item) {
let attachment = item.attachment
titleLabel.text = (attachment.sourceFilename ?? "File")
detailLabel.text = "\(OWSFormat.formatFileSize(UInt(attachment.byteCount)))"
detailLabel.text = "\(Format.fileSize(attachment.byteCount)))"
timeLabel.text = Date(
timeIntervalSince1970: TimeInterval(item.interactionTimestampMs / 1000)
).formattedForDisplay

View File

@ -34,12 +34,9 @@ class PhotoCollectionPickerViewModel: SessionTableViewModel<NoNav, PhotoCollecti
// MARK: - Content
override var title: String { "NOTIFICATIONS_STYLE_SOUND_TITLE".localized() }
override var observableTableData: ObservableData { _observableTableData }
private var _settingsData: [SectionModel] = []
public override var settingsData: [SectionModel] { _settingsData }
public override var observableSettingsData: ObservableData { _observableSettingsData }
private lazy var _observableSettingsData: ObservableData = {
private lazy var _observableTableData: ObservableData = {
self.photoCollections
.map { collections in
[
@ -49,15 +46,15 @@ class PhotoCollectionPickerViewModel: SessionTableViewModel<NoNav, PhotoCollecti
let contents: PhotoCollectionContents = collection.contents()
let photoMediaSize: PhotoMediaSize = PhotoMediaSize(
thumbnailSize: CGSize(
width: IconSize.veryLarge.size,
height: IconSize.veryLarge.size
width: IconSize.extraLarge.size,
height: IconSize.extraLarge.size
)
)
let lastAssetItem: PhotoPickerAssetItem? = contents.lastAssetItem(photoMediaSize: photoMediaSize)
return SessionCell.Info(
id: Item(id: collection.id),
leftAccessory: .iconAsync(size: .veryLarge, shouldFill: true) { imageView in
leftAccessory: .iconAsync(size: .extraLarge, shouldFill: true) { imageView in
// Note: We need to capture 'lastAssetItem' otherwise it'll be released and we won't
// be able to load the thumbnail
lastAssetItem?.asyncThumbnail { [weak imageView] image in
@ -76,14 +73,9 @@ class PhotoCollectionPickerViewModel: SessionTableViewModel<NoNav, PhotoCollecti
}
.removeDuplicates()
.eraseToAnyPublisher()
.mapToSessionTableViewData(for: self)
}()
// MARK: - Functions
public override func updateSettings(_ updatedSettings: [SectionModel]) {
self._settingsData = updatedSettings
}
// MARK: PhotoLibraryDelegate
func photoLibraryDidChange(_ photoLibrary: PhotoLibrary) {

View File

@ -20,7 +20,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
private var loadingViewController: LoadingViewController?
/// This needs to be a lazy variable to ensure it doesn't get initialized before it actually needs to be used
lazy var poller: Poller = Poller()
lazy var poller: CurrentUserPoller = CurrentUserPoller()
// MARK: - Lifecycle
@ -564,7 +564,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
public func startPollersIfNeeded(shouldStartGroupPollers: Bool = true) {
guard Identity.userExists() else { return }
poller.startIfNeeded()
poller.start()
guard shouldStartGroupPollers else { return }
@ -574,7 +574,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
public func stopPollers(shouldStopUserPoller: Bool = true) {
if shouldStopUserPoller {
poller.stop()
poller.stopAllPollers()
}
ClosedGroupPoller.shared.stopAllPollers()

View File

@ -17,7 +17,6 @@
#import <Reachability/Reachability.h>
#import <SignalCoreKit/Cryptography.h>
#import <SessionMessagingKit/OWSAudioPlayer.h>
#import <SignalUtilitiesKit/OWSFormat.h>
#import <SignalUtilitiesKit/OWSViewController.h>
#import <SignalUtilitiesKit/UIFont+OWS.h>
#import <SessionUtilitiesKit/UIView+OWS.h>

View File

@ -596,3 +596,5 @@
"MESSAGE_STATE_READ" = "Read";
"MESSAGE_STATE_SENT" = "Sent";
"MESSAGE_REQUEST_PENDING_APPROVAL_INFO" = "You will be able to send voice messages and attachments once the recipient has approved this message request";
"REMOVE_AVATAR" = "Remove";
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";

View File

@ -596,3 +596,5 @@
"MESSAGE_STATE_READ" = "Read";
"MESSAGE_STATE_SENT" = "Sent";
"MESSAGE_REQUEST_PENDING_APPROVAL_INFO" = "You will be able to send voice messages and attachments once the recipient has approved this message request";
"REMOVE_AVATAR" = "Remove";
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";

View File

@ -596,3 +596,5 @@
"MESSAGE_STATE_READ" = "Read";
"MESSAGE_STATE_SENT" = "Sent";
"MESSAGE_REQUEST_PENDING_APPROVAL_INFO" = "You will be able to send voice messages and attachments once the recipient has approved this message request";
"REMOVE_AVATAR" = "Remove";
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";

View File

@ -596,3 +596,5 @@
"MESSAGE_STATE_READ" = "خوانده شد";
"MESSAGE_STATE_SENT" = "ارسال شد";
"MESSAGE_REQUEST_PENDING_APPROVAL_INFO" = "You will be able to send voice messages and attachments once the recipient has approved this message request";
"REMOVE_AVATAR" = "Remove";
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";

View File

@ -596,3 +596,5 @@
"MESSAGE_STATE_READ" = "Read";
"MESSAGE_STATE_SENT" = "Sent";
"MESSAGE_REQUEST_PENDING_APPROVAL_INFO" = "You will be able to send voice messages and attachments once the recipient has approved this message request";
"REMOVE_AVATAR" = "Remove";
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";

View File

@ -596,3 +596,5 @@
"MESSAGE_STATE_READ" = "Read";
"MESSAGE_STATE_SENT" = "Sent";
"MESSAGE_REQUEST_PENDING_APPROVAL_INFO" = "You will be able to send voice messages and attachments once the recipient has approved this message request";
"REMOVE_AVATAR" = "Remove";
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";

View File

@ -596,3 +596,5 @@
"MESSAGE_STATE_READ" = "Read";
"MESSAGE_STATE_SENT" = "Sent";
"MESSAGE_REQUEST_PENDING_APPROVAL_INFO" = "You will be able to send voice messages and attachments once the recipient has approved this message request";
"REMOVE_AVATAR" = "Remove";
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";

View File

@ -596,3 +596,5 @@
"MESSAGE_STATE_READ" = "Read";
"MESSAGE_STATE_SENT" = "Sent";
"MESSAGE_REQUEST_PENDING_APPROVAL_INFO" = "You will be able to send voice messages and attachments once the recipient has approved this message request";
"REMOVE_AVATAR" = "Remove";
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";

View File

@ -596,3 +596,5 @@
"MESSAGE_STATE_READ" = "Read";
"MESSAGE_STATE_SENT" = "Sent";
"MESSAGE_REQUEST_PENDING_APPROVAL_INFO" = "You will be able to send voice messages and attachments once the recipient has approved this message request";
"REMOVE_AVATAR" = "Remove";
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";

View File

@ -596,3 +596,5 @@
"MESSAGE_STATE_READ" = "Read";
"MESSAGE_STATE_SENT" = "Sent";
"MESSAGE_REQUEST_PENDING_APPROVAL_INFO" = "You will be able to send voice messages and attachments once the recipient has approved this message request";
"REMOVE_AVATAR" = "Remove";
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";

View File

@ -596,3 +596,5 @@
"MESSAGE_STATE_READ" = "Read";
"MESSAGE_STATE_SENT" = "Sent";
"MESSAGE_REQUEST_PENDING_APPROVAL_INFO" = "You will be able to send voice messages and attachments once the recipient has approved this message request";
"REMOVE_AVATAR" = "Remove";
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";

View File

@ -596,3 +596,5 @@
"MESSAGE_STATE_READ" = "Read";
"MESSAGE_STATE_SENT" = "Sent";
"MESSAGE_REQUEST_PENDING_APPROVAL_INFO" = "You will be able to send voice messages and attachments once the recipient has approved this message request";
"REMOVE_AVATAR" = "Remove";
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";

View File

@ -596,3 +596,5 @@
"MESSAGE_STATE_READ" = "Read";
"MESSAGE_STATE_SENT" = "Sent";
"MESSAGE_REQUEST_PENDING_APPROVAL_INFO" = "You will be able to send voice messages and attachments once the recipient has approved this message request";
"REMOVE_AVATAR" = "Remove";
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";

View File

@ -596,3 +596,5 @@
"MESSAGE_STATE_READ" = "Read";
"MESSAGE_STATE_SENT" = "Sent";
"MESSAGE_REQUEST_PENDING_APPROVAL_INFO" = "You will be able to send voice messages and attachments once the recipient has approved this message request";
"REMOVE_AVATAR" = "Remove";
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";

View File

@ -596,3 +596,5 @@
"MESSAGE_STATE_READ" = "Read";
"MESSAGE_STATE_SENT" = "Sent";
"MESSAGE_REQUEST_PENDING_APPROVAL_INFO" = "You will be able to send voice messages and attachments once the recipient has approved this message request";
"REMOVE_AVATAR" = "Remove";
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";

View File

@ -596,3 +596,5 @@
"MESSAGE_STATE_READ" = "Read";
"MESSAGE_STATE_SENT" = "Sent";
"MESSAGE_REQUEST_PENDING_APPROVAL_INFO" = "You will be able to send voice messages and attachments once the recipient has approved this message request";
"REMOVE_AVATAR" = "Remove";
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";

View File

@ -596,3 +596,5 @@
"MESSAGE_STATE_READ" = "Read";
"MESSAGE_STATE_SENT" = "Sent";
"MESSAGE_REQUEST_PENDING_APPROVAL_INFO" = "You will be able to send voice messages and attachments once the recipient has approved this message request";
"REMOVE_AVATAR" = "Remove";
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";

View File

@ -596,3 +596,5 @@
"MESSAGE_STATE_READ" = "Read";
"MESSAGE_STATE_SENT" = "Sent";
"MESSAGE_REQUEST_PENDING_APPROVAL_INFO" = "You will be able to send voice messages and attachments once the recipient has approved this message request";
"REMOVE_AVATAR" = "Remove";
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";

View File

@ -596,3 +596,5 @@
"MESSAGE_STATE_READ" = "Read";
"MESSAGE_STATE_SENT" = "Sent";
"MESSAGE_REQUEST_PENDING_APPROVAL_INFO" = "You will be able to send voice messages and attachments once the recipient has approved this message request";
"REMOVE_AVATAR" = "Remove";
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";

View File

@ -596,3 +596,5 @@
"MESSAGE_STATE_READ" = "Read";
"MESSAGE_STATE_SENT" = "Sent";
"MESSAGE_REQUEST_PENDING_APPROVAL_INFO" = "You will be able to send voice messages and attachments once the recipient has approved this message request";
"REMOVE_AVATAR" = "Remove";
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";

View File

@ -596,3 +596,5 @@
"MESSAGE_STATE_READ" = "Read";
"MESSAGE_STATE_SENT" = "Sent";
"MESSAGE_REQUEST_PENDING_APPROVAL_INFO" = "You will be able to send voice messages and attachments once the recipient has approved this message request";
"REMOVE_AVATAR" = "Remove";
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";

View File

@ -596,3 +596,5 @@
"MESSAGE_STATE_READ" = "Read";
"MESSAGE_STATE_SENT" = "Sent";
"MESSAGE_REQUEST_PENDING_APPROVAL_INFO" = "You will be able to send voice messages and attachments once the recipient has approved this message request";
"REMOVE_AVATAR" = "Remove";
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";

View File

@ -538,12 +538,7 @@ class NotificationActionHandler {
variant: .standardOutgoing,
body: replyText,
timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000)),
hasMention: Interaction.isUserMentioned(
db,
threadId: threadId,
threadVariant: thread.variant,
body: replyText
),
hasMention: Interaction.isUserMentioned(db, threadId: threadId, body: replyText),
expiresInSeconds: try? DisappearingMessagesConfiguration
.select(.durationSeconds)
.filter(id: threadId)

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.
import Foundation
import Combine
import GRDB
import DifferenceKit
import SessionUIKit
import SignalUtilitiesKit
public class BlockedContactsViewModel {
public typealias SectionModel = ArraySection<Section, SessionCell.Info<Profile>>
class BlockedContactsViewModel: SessionTableViewModel<NoNav, BlockedContactsViewModel.Section, Profile> {
// MARK: - Section
public enum Section: Differentiable {
public enum Section: SessionTableSection {
case contacts
case loadMore
var style: SessionTableSectionStyle {
switch self {
case .contacts: return .none
case .loadMore: return .loadMore
}
}
}
// MARK: - Variables
@ -21,14 +28,16 @@ public class BlockedContactsViewModel {
// MARK: - Initialization
init() {
self.pagedDataObserver = nil
override init() {
_pagedDataObserver = nil
super.init()
// Note: Since this references self we need to finish initializing before setting it, we
// also want to skip the initial query and trigger it async so that the push animation
// doesn't stutter (it should load basically immediately but without this there is a
// distinct stutter)
self.pagedDataObserver = PagedDatabaseObserver(
_pagedDataObserver = PagedDatabaseObserver(
pagedTable: Profile.self,
pageSize: BlockedContactsViewModel.pageSize,
idColumn: .id,
@ -63,12 +72,13 @@ public class BlockedContactsViewModel {
),
onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in
PagedData.processAndTriggerUpdates(
updatedData: self?.process(data: updatedData, for: updatedPageInfo),
currentDataRetriever: { self?.contactData },
onDataChange: self?.onContactChange,
onUnobservedDataChange: { updatedData, changeset in
self?.unobservedContactDataChanges = (updatedData, changeset)
}
updatedData: self?.process(data: updatedData, for: updatedPageInfo)
.mapToSessionTableViewData(for: self),
currentDataRetriever: { self?.tableData },
onDataChange: { updatedData, changeset in
self?.contactDataSubject.send((updatedData, changeset))
},
onUnobservedDataChange: { _, _ in }
)
}
)
@ -76,59 +86,80 @@ public class BlockedContactsViewModel {
// Run the initial query on a background thread so we don't block the push transition
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
// The `.pageBefore` will query from a `0` offset loading the first page
self?.pagedDataObserver?.load(.pageBefore)
self?._pagedDataObserver?.load(.pageBefore)
}
}
// MARK: - Contact Data
public private(set) var selectedContactIds: Set<String> = []
public private(set) var unobservedContactDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>)?
public private(set) var contactData: [SectionModel] = []
public private(set) var pagedDataObserver: PagedDatabaseObserver<Profile, DataModel>?
public var onContactChange: (([SectionModel], StagedChangeset<[SectionModel]>) -> ())? {
didSet {
// When starting to observe interaction changes we want to trigger a UI update just in case the
// data was changed while we weren't observing
if let unobservedContactDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>) = self.unobservedContactDataChanges {
onContactChange?(unobservedContactDataChanges.0 , unobservedContactDataChanges.1)
self.unobservedContactDataChanges = nil
}
}
override var title: String { "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_TITLE".localized() }
override var emptyStateTextPublisher: AnyPublisher<String?, Never> {
Just("CONVERSATION_SETTINGS_BLOCKED_CONTACTS_EMPTY_STATE".localized())
.eraseToAnyPublisher()
}
private func process(data: [DataModel], for pageInfo: PagedData.PageInfo) -> [SectionModel] {
// Update the 'selectedContactIds' to only include selected contacts which are within the
// data (ie. handle profile deletions)
let profileIds: Set<String> = data.map { $0.id }.asSet()
selectedContactIds = selectedContactIds.intersection(profileIds)
private let contactDataSubject: CurrentValueSubject<([SectionModel], StagedChangeset<[SectionModel]>), Never> = CurrentValueSubject(([], StagedChangeset()))
private let selectedContactIdsSubject: CurrentValueSubject<Set<String>, Never> = CurrentValueSubject([])
private var _pagedDataObserver: PagedDatabaseObserver<Profile, DataModel>?
public override var pagedDataObserver: TransactionObserver? { _pagedDataObserver }
public override var observableTableData: ObservableData { _observableTableData }
private lazy var _observableTableData: ObservableData = contactDataSubject
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
override var footerButtonInfo: AnyPublisher<SessionButton.Info?, Never> {
selectedContactIdsSubject
.prepend([])
.map { selectedContactIds in
SessionButton.Info(
style: .destructive,
title: "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK".localized(),
isEnabled: !selectedContactIds.isEmpty,
onTap: { [weak self] in self?.unblockTapped() }
)
}
.eraseToAnyPublisher()
}
// MARK: - Functions
override func loadPageAfter() { _pagedDataObserver?.load(.pageAfter) }
private func process(
data: [DataModel],
for pageInfo: PagedData.PageInfo
) -> [SectionModel] {
return [
[
SectionModel(
section: .contacts,
elements: data
.sorted { lhs, rhs -> Bool in
lhs.profile.displayName() > rhs.profile.displayName()
lhs.profile.displayName() < rhs.profile.displayName()
}
.map { model -> SessionCell.Info<Profile> in
.map { [weak self] model -> SessionCell.Info<Profile> in
SessionCell.Info(
id: model.profile,
leftAccessory: .profile(model.profile.id, model.profile),
leftAccessory: .profile(id: model.profile.id, profile: model.profile),
title: model.profile.displayName(),
rightAccessory: .radio(
isSelected: { [weak self] in
self?.selectedContactIds.contains(model.profile.id) == true
isSelected: {
self?.selectedContactIdsSubject.value.contains(model.profile.id) == true
}
),
onTap: { [weak self] in
guard self?.selectedContactIds.contains(model.profile.id) == true else {
self?.selectedContactIds.insert(model.profile.id)
return
onTap: {
var updatedSelectedIds: Set<String> = (self?.selectedContactIdsSubject.value ?? [])
if !updatedSelectedIds.contains(model.profile.id) {
updatedSelectedIds.insert(model.profile.id)
}
else {
updatedSelectedIds.remove(model.profile.id)
}
self?.selectedContactIds.remove(model.profile.id)
self?.selectedContactIdsSubject.send(updatedSelectedIds)
}
)
}
@ -210,7 +241,7 @@ public class BlockedContactsViewModel {
confirmTitle: "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK_CONFIRMATION_ACTON".localized(),
confirmStyle: .danger,
cancelStyle: .alert_text
) { _ in
) { [weak self] _ in
// Unblock the contacts
Storage.shared.write { db in
_ = try Contact
@ -220,6 +251,8 @@ public class BlockedContactsViewModel {
// Force a config sync
try MessageSender.syncConfiguration(db, forceSyncNow: true).sinkUntilComplete()
}
self?.selectedContactIdsSubject.send([])
}
)
self.transitionToScreen(confirmationModal, transitionType: .present)
@ -242,8 +275,8 @@ public class BlockedContactsViewModel {
static func query(
filterSQL: SQL,
orderSQL: SQL
) -> (([Int64]) -> AdaptedFetchRequest<SQLRequest<DataModel>>) {
return { rowIds -> AdaptedFetchRequest<SQLRequest<DataModel>> in
) -> (([Int64]) -> any FetchRequest<DataModel>) {
return { rowIds -> any FetchRequest<DataModel> in
let profile: TypedTableAlias<Profile> = TypedTableAlias()
/// **Note:** The `numColumnsBeforeProfile` value **MUST** match the number of fields before

View File

@ -26,7 +26,7 @@ class ConversationSettingsViewModel: SessionTableViewModel<NoNav, ConversationSe
var style: SessionTableSectionStyle {
switch self {
case .blockedContacts: return .padding
default: return .title
default: return .titleRoundedContent
}
}
}
@ -35,10 +35,7 @@ class ConversationSettingsViewModel: SessionTableViewModel<NoNav, ConversationSe
override var title: String { "CONVERSATION_SETTINGS_TITLE".localized() }
private var _settingsData: [SectionModel] = []
public override var settingsData: [SectionModel] { _settingsData }
public override var observableSettingsData: ObservableData { _observableSettingsData }
public override var observableTableData: ObservableData { _observableTableData }
/// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise
/// performance https://github.com/groue/GRDB.swift#valueobservation-performance
@ -47,7 +44,7 @@ class ConversationSettingsViewModel: SessionTableViewModel<NoNav, ConversationSe
/// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own
/// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`)
/// just in case the database has changed between the two reads - unfortunately it doesn't look like there is a way to prevent this
private lazy var _observableSettingsData: ObservableData = ValueObservation
private lazy var _observableTableData: ObservableData = ValueObservation
.trackingConstantRegion { db -> [SectionModel] in
return [
SectionModel(
@ -92,10 +89,14 @@ class ConversationSettingsViewModel: SessionTableViewModel<NoNav, ConversationSe
SessionCell.Info(
id: .blockedContacts,
title: "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_TITLE".localized(),
tintColor: .danger,
shouldHaveBackground: false,
styling: SessionCell.StyleInfo(
tintColor: .danger,
backgroundStyle: .noBackground
),
onTap: { [weak self] in
self?.transitionToScreen(BlockedContactsViewController())
self?.transitionToScreen(
SessionTableViewController(viewModel: BlockedContactsViewModel())
)
}
)
]
@ -104,10 +105,5 @@ class ConversationSettingsViewModel: SessionTableViewModel<NoNav, ConversationSe
}
.removeDuplicates()
.publisher(in: Storage.shared)
// MARK: - Functions
public override func updateSettings(_ updatedSettings: [SectionModel]) {
self._settingsData = updatedSettings
}
.mapToSessionTableViewData(for: self)
}

View File

@ -25,10 +25,7 @@ class HelpViewModel: SessionTableViewModel<NoNav, HelpViewModel.Section, HelpVie
override var title: String { "HELP_TITLE".localized() }
private var _settingsData: [SectionModel] = []
public override var settingsData: [SectionModel] { _settingsData }
public override var observableSettingsData: ObservableData { _observableSettingsData }
public override var observableTableData: ObservableData { _observableTableData }
/// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise
/// performance https://github.com/groue/GRDB.swift#valueobservation-performance
@ -37,7 +34,7 @@ class HelpViewModel: SessionTableViewModel<NoNav, HelpViewModel.Section, HelpVie
/// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own
/// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`)
/// just in case the database has changed between the two reads - unfortunately it doesn't look like there is a way to prevent this
private lazy var _observableSettingsData: ObservableData = ValueObservation
private lazy var _observableTableData: ObservableData = ValueObservation
.trackingConstantRegion { db -> [SectionModel] in
return [
SectionModel(
@ -50,7 +47,7 @@ class HelpViewModel: SessionTableViewModel<NoNav, HelpViewModel.Section, HelpVie
rightAccessory: .highlightingBackgroundLabel(
title: "HELP_REPORT_BUG_ACTION_TITLE".localized()
),
onTap: { HelpViewModel.shareLogs(targetView: $0) }
onTapView: { HelpViewModel.shareLogs(targetView: $0) }
)
]
),
@ -142,12 +139,9 @@ class HelpViewModel: SessionTableViewModel<NoNav, HelpViewModel.Section, HelpVie
}
.removeDuplicates()
.publisher(in: Storage.shared)
.mapToSessionTableViewData(for: self)
// MARK: - Functions
public override func updateSettings(_ updatedSettings: [SectionModel]) {
self._settingsData = updatedSettings
}
public static func shareLogs(
viewControllerToDismiss: UIViewController? = nil,

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() }
private var _settingsData: [SectionModel] = []
public override var settingsData: [SectionModel] { _settingsData }
public override var observableSettingsData: ObservableData { _observableSettingsData }
public override var observableTableData: ObservableData { _observableTableData }
/// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise
/// performance https://github.com/groue/GRDB.swift#valueobservation-performance
@ -43,7 +40,7 @@ class NotificationContentViewModel: SessionTableViewModel<NoNav, NotificationSet
/// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own
/// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`)
/// just in case the database has changed between the two reads - unfortunately it doesn't look like there is a way to prevent this
private lazy var _observableSettingsData: ObservableData = ValueObservation
private lazy var _observableTableData: ObservableData = ValueObservation
.trackingConstantRegion { [storage] db -> [SectionModel] in
let currentSelection: Preferences.NotificationPreviewType? = db[.preferencesNotificationPreviewType]
.defaulting(to: .defaultPreviewType)
@ -73,10 +70,5 @@ class NotificationContentViewModel: SessionTableViewModel<NoNav, NotificationSet
}
.removeDuplicates()
.publisher(in: storage, scheduling: scheduler)
// MARK: - Functions
public override func updateSettings(_ updatedSettings: [SectionModel]) {
self._settingsData = updatedSettings
}
.mapToSessionTableViewData(for: self)
}

View File

@ -26,13 +26,14 @@ class NotificationSettingsViewModel: SessionTableViewModel<NoNav, NotificationSe
var style: SessionTableSectionStyle {
switch self {
case .content: return .padding
default: return .title
default: return .titleRoundedContent
}
}
}
public enum Setting: Differentiable {
case strategyUseFastMode
case strategyDeviceSettings
case styleSound
case styleSoundWhenAppIsOpen
case content
@ -42,10 +43,7 @@ class NotificationSettingsViewModel: SessionTableViewModel<NoNav, NotificationSe
override var title: String { "NOTIFICATIONS_TITLE".localized() }
private var _settingsData: [SectionModel] = []
public override var settingsData: [SectionModel] { _settingsData }
public override var observableSettingsData: ObservableData { _observableSettingsData }
public override var observableTableData: ObservableData { _observableTableData }
/// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise
/// performance https://github.com/groue/GRDB.swift#valueobservation-performance
@ -54,7 +52,7 @@ class NotificationSettingsViewModel: SessionTableViewModel<NoNav, NotificationSe
/// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own
/// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`)
/// just in case the database has changed between the two reads - unfortunately it doesn't look like there is a way to prevent this
private lazy var _observableSettingsData: ObservableData = ValueObservation
private lazy var _observableTableData: ObservableData = ValueObservation
.trackingConstantRegion { db -> [SectionModel] in
let notificationSound: Preferences.Sound = db[.defaultNotificationSound]
.defaulting(to: Preferences.Sound.defaultNotificationSound)
@ -72,9 +70,9 @@ class NotificationSettingsViewModel: SessionTableViewModel<NoNav, NotificationSe
rightAccessory: .toggle(
.userDefaults(UserDefaults.standard, key: "isUsingFullAPNs")
),
extraAction: SessionCell.ExtraAction(
title: "NOTIFICATIONS_STRATEGY_FAST_MODE_ACTION".localized(),
onTap: { UIApplication.shared.openSystemSettings() }
styling: SessionCell.StyleInfo(
allowedSeparators: [.top],
customPadding: SessionCell.Padding(bottom: Values.verySmallSpacing)
),
onTap: {
UserDefaults.standard.set(
@ -85,6 +83,19 @@ class NotificationSettingsViewModel: SessionTableViewModel<NoNav, NotificationSe
// Force sync the push tokens on change
SyncPushTokensJob.run(uploadOnlyIfStale: false)
}
),
SessionCell.Info(
id: .strategyDeviceSettings,
title: SessionCell.TextInfo(
"NOTIFICATIONS_STRATEGY_FAST_MODE_ACTION".localized(),
font: .subtitleBold
),
styling: SessionCell.StyleInfo(
tintColor: .settings_tertiaryAction,
allowedSeparators: [.bottom],
customPadding: SessionCell.Padding(top: Values.verySmallSpacing)
),
onTap: { UIApplication.shared.openSystemSettings() }
)
]
),
@ -137,10 +148,5 @@ class NotificationSettingsViewModel: SessionTableViewModel<NoNav, NotificationSe
}
.removeDuplicates()
.publisher(in: Storage.shared)
// MARK: - Functions
public override func updateSettings(_ updatedSettings: [SectionModel]) {
self._settingsData = updatedSettings
}
.mapToSessionTableViewData(for: self)
}

View File

@ -76,10 +76,7 @@ class NotificationSoundViewModel: SessionTableViewModel<NotificationSoundViewMod
override var title: String { "NOTIFICATIONS_STYLE_SOUND_TITLE".localized() }
private var _settingsData: [SectionModel] = []
public override var settingsData: [SectionModel] { _settingsData }
public override var observableSettingsData: ObservableData { _observableSettingsData }
public override var observableTableData: ObservableData { _observableTableData }
/// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise
/// performance https://github.com/groue/GRDB.swift#valueobservation-performance
@ -88,7 +85,7 @@ class NotificationSoundViewModel: SessionTableViewModel<NotificationSoundViewMod
/// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own
/// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`)
/// just in case the database has changed between the two reads - unfortunately it doesn't look like there is a way to prevent this
private lazy var _observableSettingsData: ObservableData = ValueObservation
private lazy var _observableTableData: ObservableData = ValueObservation
.trackingConstantRegion { [weak self] db -> [SectionModel] in
self?.storedSelection = try {
guard let threadId: String = self?.threadId else {
@ -150,12 +147,9 @@ class NotificationSoundViewModel: SessionTableViewModel<NotificationSoundViewMod
}
.removeDuplicates()
.publisher(in: Storage.shared)
.mapToSessionTableViewData(for: self)
// MARK: - Functions
public override func updateSettings(_ updatedSettings: [SectionModel]) {
self._settingsData = updatedSettings
}
private func saveChanges() {
guard let currentSelection: Preferences.Sound = self.currentSelection.value else { return }

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 {
@ -76,10 +76,7 @@ class PrivacySettingsViewModel: SessionTableViewModel<PrivacySettingsViewModel.N
override var title: String { "PRIVACY_TITLE".localized() }
private var _settingsData: [SectionModel] = []
public override var settingsData: [SectionModel] { _settingsData }
public override var observableSettingsData: ObservableData { _observableSettingsData }
public override var observableTableData: ObservableData { _observableTableData }
/// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise
/// performance https://github.com/groue/GRDB.swift#valueobservation-performance
@ -88,7 +85,7 @@ class PrivacySettingsViewModel: SessionTableViewModel<PrivacySettingsViewModel.N
/// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own
/// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`)
/// just in case the database has changed between the two reads - unfortunately it doesn't look like there is a way to prevent this
private lazy var _observableSettingsData: ObservableData = ValueObservation
private lazy var _observableTableData: ObservableData = ValueObservation
.trackingConstantRegion { db -> [SectionModel] in
return [
SectionModel(
@ -128,34 +125,40 @@ class PrivacySettingsViewModel: SessionTableViewModel<PrivacySettingsViewModel.N
elements: [
SessionCell.Info(
id: .typingIndicators,
title: "PRIVACY_TYPING_INDICATORS_TITLE".localized(),
subtitle: "PRIVACY_TYPING_INDICATORS_DESCRIPTION".localized(),
subtitleExtraViewGenerator: {
let targetHeight: CGFloat = 20
let targetWidth: CGFloat = ceil(20 * (targetHeight / 12))
let result: UIView = UIView(
frame: CGRect(x: 0, y: 0, width: targetWidth, height: targetHeight)
)
result.set(.width, to: targetWidth)
result.set(.height, to: targetHeight)
// Use a transform scale to reduce the size of the typing indicator to the
// desired size (this way the animation remains intact)
let cell: TypingIndicatorCell = TypingIndicatorCell()
cell.transform = CGAffineTransform.scale(targetHeight / cell.bounds.height)
cell.typingIndicatorView.startAnimation()
result.addSubview(cell)
// Note: Because we are messing with the transform these values don't work
// logically so we inset the positioning to make it look visually centered
// within the layout inspector
cell.center(.vertical, in: result, withInset: -(targetHeight * 0.15))
cell.center(.horizontal, in: result, withInset: -(targetWidth * 0.35))
cell.set(.width, to: .width, of: result)
cell.set(.height, to: .height, of: result)
return result
},
title: SessionCell.TextInfo(
"PRIVACY_TYPING_INDICATORS_TITLE".localized(),
font: .title
),
subtitle: SessionCell.TextInfo(
"PRIVACY_TYPING_INDICATORS_DESCRIPTION".localized(),
font: .subtitle,
extraViewGenerator: {
let targetHeight: CGFloat = 20
let targetWidth: CGFloat = ceil(20 * (targetHeight / 12))
let result: UIView = UIView(
frame: CGRect(x: 0, y: 0, width: targetWidth, height: targetHeight)
)
result.set(.width, to: targetWidth)
result.set(.height, to: targetHeight)
// Use a transform scale to reduce the size of the typing indicator to the
// desired size (this way the animation remains intact)
let cell: TypingIndicatorCell = TypingIndicatorCell()
cell.transform = CGAffineTransform.scale(targetHeight / cell.bounds.height)
cell.typingIndicatorView.startAnimation()
result.addSubview(cell)
// Note: Because we are messing with the transform these values don't work
// logically so we inset the positioning to make it look visually centered
// within the layout inspector
cell.center(.vertical, in: result, withInset: -(targetHeight * 0.15))
cell.center(.horizontal, in: result, withInset: -(targetWidth * 0.35))
cell.set(.width, to: .width, of: result)
cell.set(.height, to: .height, of: result)
return result
}
),
rightAccessory: .toggle(.settingBool(key: .typingIndicatorsEnabled)),
onTap: {
Storage.shared.write { db in
@ -189,7 +192,9 @@ class PrivacySettingsViewModel: SessionTableViewModel<PrivacySettingsViewModel.N
title: "PRIVACY_CALLS_TITLE".localized(),
subtitle: "PRIVACY_CALLS_DESCRIPTION".localized(),
rightAccessory: .toggle(.settingBool(key: .areCallsEnabled)),
accessibilityLabel: "Allow voice and video calls",
accessibility: SessionCell.Accessibility(
label: "Allow voice and video calls"
),
confirmationInfo: ConfirmationModal.Info(
title: "PRIVACY_CALLS_WARNING_TITLE".localized(),
explanation: "PRIVACY_CALLS_WARNING_DESCRIPTION".localized(),
@ -211,10 +216,5 @@ class PrivacySettingsViewModel: SessionTableViewModel<PrivacySettingsViewModel.N
}
.removeDuplicates()
.publisher(in: Storage.shared)
// MARK: - Functions
public override func updateSettings(_ updatedSettings: [SectionModel]) {
self._settingsData = updatedSettings
}
.mapToSessionTableViewData(for: self)
}

View File

@ -26,12 +26,33 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
public enum Section: SessionTableSection {
case profileInfo
case sessionId
case menus
case footer
var title: String? {
switch self {
case .sessionId: return "your_session_id".localized()
default: return nil
}
}
var style: SessionTableSectionStyle {
switch self {
case .sessionId: return .titleSeparator
case .menus: return .padding
default: return .none
}
}
}
public enum Item: Differentiable {
case profileInfo
case avatar
case profileName
case sessionId
case idActions
case path
case privacy
case notifications
@ -47,7 +68,18 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
// MARK: - Variables
private let userSessionId: String
private lazy var imagePickerHandler: ImagePickerHandler = ImagePickerHandler(viewModel: self)
private lazy var imagePickerHandler: ImagePickerHandler = ImagePickerHandler(
onTransition: { [weak self] in self?.transitionToScreen($0, transitionType: $1) },
onImagePicked: { [weak self] resultImage, resultImagePath in
self?.updateProfile(
name: (self?.oldDisplayName ?? ""),
profilePicture: resultImage,
profilePictureFilePath: resultImagePath,
isUpdatingDisplayName: false,
isUpdatingProfilePicture: true
)
}
)
fileprivate var oldDisplayName: String
private var editedDisplayName: String?
@ -63,8 +95,20 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
// MARK: - Navigation
lazy var navState: AnyPublisher<NavState, Never> = {
isEditing
.map { isEditing in (isEditing ? .editing : .standard) }
Publishers
.CombineLatest(
isEditing
.map { isEditing in isEditing },
textChanged
.handleEvents(
receiveOutput: { [weak self] value, _ in
self?.editedDisplayName = value
}
)
.filter { _ in false }
.prepend((nil, .profileName))
)
.map { isEditing, _ -> NavState in (isEditing ? .editing : .standard) }
.removeDuplicates()
.prepend(.standard) // Initial value
.eraseToAnyPublisher()
@ -176,10 +220,7 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
override var title: String { "vc_settings_title".localized() }
private var _settingsData: [SectionModel] = []
public override var settingsData: [SectionModel] { _settingsData }
public override var observableSettingsData: ObservableData { _observableSettingsData }
public override var observableTableData: ObservableData { _observableTableData }
/// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise
/// performance https://github.com/groue/GRDB.swift#valueobservation-performance
@ -188,8 +229,8 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
/// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own
/// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`)
/// just in case the database has changed between the two reads - unfortunately it doesn't look like there is a way to prevent this
private lazy var _observableSettingsData: ObservableData = ValueObservation
.trackingConstantRegion { db -> [SectionModel] in
private lazy var _observableTableData: ObservableData = ValueObservation
.trackingConstantRegion { [weak self] db -> [SectionModel] in
let userPublicKey: String = getUserHexEncodedPublicKey(db)
let profile: Profile = Profile.fetchOrCreateCurrentUser(db)
@ -198,38 +239,82 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
model: .profileInfo,
elements: [
SessionCell.Info(
id: .profileInfo,
leftAccessory: .threadInfo(
threadViewModel: SessionThreadViewModel(
threadId: profile.id,
threadIsNoteToSelf: true,
contactProfile: profile
),
style: SessionCell.Accessory.ThreadInfoStyle(
separatorTitle: "your_session_id".localized(),
descriptionStyle: .monoLarge,
descriptionActions: [
SessionCell.Accessory.ThreadInfoStyle.Action(
title: "copy".localized(),
run: { [weak self] button in
self?.copySessionId(profile.id, button: button)
}
),
SessionCell.Accessory.ThreadInfoStyle.Action(
title: "share".localized(),
run: { [weak self] _ in
self?.shareSessionId(profile.id)
}
)
]
),
avatarTapped: { [weak self] in self?.updateProfilePicture() },
titleTapped: { [weak self] in self?.setIsEditing(true) },
titleChanged: { [weak self] text in self?.editedDisplayName = text }
id: .avatar,
accessory: .profile(
id: profile.id,
size: .extraLarge,
profile: profile
),
title: profile.displayName(),
shouldHaveBackground: false
styling: SessionCell.StyleInfo(
alignment: .centerHugging,
customPadding: SessionCell.Padding(bottom: Values.smallSpacing),
backgroundStyle: .noBackground
),
onTap: {
self?.updateProfilePicture(
hasCustomImage: ProfileManager.hasProfileImageData(
with: profile.profilePictureFileName
)
)
}
),
SessionCell.Info(
id: .profileName,
title: SessionCell.TextInfo(
profile.displayName(),
font: .titleLarge,
alignment: .center,
interaction: .editable
),
styling: SessionCell.StyleInfo(
alignment: .centerHugging,
customPadding: SessionCell.Padding(top: Values.smallSpacing),
backgroundStyle: .noBackground
),
onTap: { self?.setIsEditing(true) }
)
]
),
SectionModel(
model: .sessionId,
elements: [
SessionCell.Info(
id: .sessionId,
title: SessionCell.TextInfo(
profile.id,
font: .monoLarge,
alignment: .center,
interaction: .copy
),
styling: SessionCell.StyleInfo(
customPadding: SessionCell.Padding(bottom: Values.smallSpacing),
backgroundStyle: .noBackground
)
),
SessionCell.Info(
id: .idActions,
leftAccessory: .button(
style: .bordered,
title: "copy".localized(),
run: { button in
self?.copySessionId(profile.id, button: button)
}
),
rightAccessory: .button(
style: .bordered,
title: "share".localized(),
run: { _ in
self?.shareSessionId(profile.id)
}
),
styling: SessionCell.StyleInfo(
customPadding: SessionCell.Padding(
top: Values.smallSpacing,
leading: 0,
trailing: 0
),
backgroundStyle: .noBackground
)
)
]
),
@ -238,7 +323,7 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
elements: [
SessionCell.Info(
id: .path,
leftAccessory: .customView {
leftAccessory: .customView(hashValue: "PathStatusView") {
// Need to ensure this view is the same size as the icons so
// wrap it in a larger view
let result: UIView = UIView()
@ -252,7 +337,7 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
return result
},
title: "vc_path_title".localized(),
onTap: { [weak self] in self?.transitionToScreen(PathVC()) }
onTap: { self?.transitionToScreen(PathVC()) }
),
SessionCell.Info(
id: .privacy,
@ -261,7 +346,7 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
.withRenderingMode(.alwaysTemplate)
),
title: "vc_settings_privacy_button_title".localized(),
onTap: { [weak self] in
onTap: {
self?.transitionToScreen(
SessionTableViewController(viewModel: PrivacySettingsViewModel())
)
@ -274,7 +359,7 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
.withRenderingMode(.alwaysTemplate)
),
title: "vc_settings_notifications_button_title".localized(),
onTap: { [weak self] in
onTap: {
self?.transitionToScreen(
SessionTableViewController(viewModel: NotificationSettingsViewModel())
)
@ -287,7 +372,7 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
.withRenderingMode(.alwaysTemplate)
),
title: "CONVERSATION_SETTINGS_TITLE".localized(),
onTap: { [weak self] in
onTap: {
self?.transitionToScreen(
SessionTableViewController(viewModel: ConversationSettingsViewModel())
)
@ -300,7 +385,7 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
.withRenderingMode(.alwaysTemplate)
),
title: "MESSAGE_REQUESTS_TITLE".localized(),
onTap: { [weak self] in
onTap: {
self?.transitionToScreen(MessageRequestsViewController())
}
),
@ -311,7 +396,7 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
.withRenderingMode(.alwaysTemplate)
),
title: "APPEARANCE_TITLE".localized(),
onTap: { [weak self] in
onTap: {
self?.transitionToScreen(AppearanceViewController())
}
),
@ -322,7 +407,7 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
.withRenderingMode(.alwaysTemplate)
),
title: "vc_settings_invite_a_friend_button_title".localized(),
onTap: { [weak self] in
onTap: {
let invitation: String = "Hey, I've been using Session to chat with complete privacy and security. Come join me! Download it at https://getsession.org/. My Session ID is \(profile.id) !"
self?.transitionToScreen(
@ -341,7 +426,7 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
.withRenderingMode(.alwaysTemplate)
),
title: "vc_settings_recovery_phrase_button_title".localized(),
onTap: { [weak self] in
onTap: {
self?.transitionToScreen(SeedModal(), transitionType: .present)
}
),
@ -352,7 +437,7 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
.withRenderingMode(.alwaysTemplate)
),
title: "HELP_TITLE".localized(),
onTap: { [weak self] in
onTap: {
self?.transitionToScreen(
SessionTableViewController(viewModel: HelpViewModel())
)
@ -365,8 +450,8 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
.withRenderingMode(.alwaysTemplate)
),
title: "vc_settings_clear_all_data_button_title".localized(),
tintColor: .danger,
onTap: { [weak self] in
styling: SessionCell.StyleInfo(tintColor: .danger),
onTap: {
self?.transitionToScreen(NukeDataModal(), transitionType: .present)
}
)
@ -376,6 +461,7 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
}
.removeDuplicates()
.publisher(in: Storage.shared)
.mapToSessionTableViewData(for: self)
public override var footerView: AnyPublisher<UIView?, Never> {
Just(VersionFooterView())
@ -383,26 +469,30 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
}
// MARK: - Functions
public override func updateSettings(_ updatedSettings: [SectionModel]) {
self._settingsData = updatedSettings
}
private func updateProfilePicture() {
private func updateProfilePicture(hasCustomImage: Bool) {
let actionSheet: UIAlertController = UIAlertController(
title: "Update Profile Picture",
message: nil,
preferredStyle: .actionSheet
)
let action = UIAlertAction(
actionSheet.addAction(UIAlertAction(
title: "MEDIA_FROM_LIBRARY_BUTTON".localized(),
style: .default,
handler: { [weak self] _ in
self?.showPhotoLibraryForAvatar()
}
)
action.accessibilityLabel = "Photo library"
actionSheet.addAction(action)
))
// Only have the 'remove' button if there is a custom avatar set
if hasCustomImage {
actionSheet.addAction(UIAlertAction(
title: "REMOVE_AVATAR".localized(),
style: .destructive,
handler: { [weak self] _ in self?.removeProfileImage() }
))
}
actionSheet.addAction(UIAlertAction(title: "cancel".localized(), style: .cancel, handler: nil))
self.transitionToScreen(actionSheet, transitionType: .present)

View File

@ -81,8 +81,10 @@ class BlockedContactCell: UITableViewCell {
public func update(with cellViewModel: BlockedContactsViewModel.DataModel, isSelected: Bool) {
profilePictureView.update(
publicKey: cellViewModel.profile.id,
threadVariant: .contact,
customImageData: nil,
profile: cellViewModel.profile,
threadVariant: .contact
additionalProfile: nil
)
selectionView.text = cellViewModel.profile.displayName()
selectionView.update(isSelected: isSelected)

View File

@ -232,11 +232,10 @@ public final class FullConversationCell: UITableViewCell {
public func updateForMessageSearchResult(with cellViewModel: SessionThreadViewModel, searchText: String) {
profilePictureView.update(
publicKey: cellViewModel.threadId,
profile: cellViewModel.profile,
additionalProfile: cellViewModel.additionalProfile,
threadVariant: cellViewModel.threadVariant,
openGroupProfilePictureData: cellViewModel.openGroupProfilePictureData,
useFallbackPicture: (cellViewModel.threadVariant == .openGroup && cellViewModel.openGroupProfilePictureData == nil)
customImageData: cellViewModel.openGroupProfilePictureData,
profile: cellViewModel.profile,
additionalProfile: cellViewModel.additionalProfile
)
isPinnedIcon.isHidden = true
@ -283,11 +282,10 @@ public final class FullConversationCell: UITableViewCell {
public func updateForContactAndGroupSearchResult(with cellViewModel: SessionThreadViewModel, searchText: String) {
profilePictureView.update(
publicKey: cellViewModel.threadId,
profile: cellViewModel.profile,
additionalProfile: cellViewModel.additionalProfile,
threadVariant: cellViewModel.threadVariant,
openGroupProfilePictureData: cellViewModel.openGroupProfilePictureData,
useFallbackPicture: (cellViewModel.threadVariant == .openGroup && cellViewModel.openGroupProfilePictureData == nil)
customImageData: cellViewModel.openGroupProfilePictureData,
profile: cellViewModel.profile,
additionalProfile: cellViewModel.additionalProfile
)
isPinnedIcon.isHidden = true
@ -362,15 +360,10 @@ public final class FullConversationCell: UITableViewCell {
)
profilePictureView.update(
publicKey: cellViewModel.threadId,
profile: cellViewModel.profile,
additionalProfile: cellViewModel.additionalProfile,
threadVariant: cellViewModel.threadVariant,
openGroupProfilePictureData: cellViewModel.openGroupProfilePictureData,
useFallbackPicture: (
cellViewModel.threadVariant == .openGroup &&
cellViewModel.openGroupProfilePictureData == nil
),
showMultiAvatarForClosedGroup: true
customImageData: cellViewModel.openGroupProfilePictureData,
profile: cellViewModel.profile,
additionalProfile: cellViewModel.additionalProfile
)
displayNameLabel.text = cellViewModel.displayName
timestampLabel.text = cellViewModel.lastInteractionDate.formattedForDisplay

View File

@ -16,10 +16,14 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
typealias SectionModel = SessionTableViewModel<NavItemId, Section, SettingItem>.SectionModel
private let viewModel: SessionTableViewModel<NavItemId, Section, SettingItem>
private var hasLoadedInitialSettingsData: Bool = false
private var hasLoadedInitialTableData: Bool = false
private var isLoadingMore: Bool = false
private var isAutoLoadingNextPage: Bool = false
private var viewHasAppeared: Bool = false
private var dataStreamJustFailed: Bool = false
private var dataChangeCancellable: AnyCancellable?
private var disposables: Set<AnyCancellable> = Set()
private var onFooterTap: (() -> ())?
public var viewModelType: AnyObject.Type { return type(of: viewModel) }
@ -32,7 +36,6 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
result.themeBackgroundColor = .clear
result.showsVerticalScrollIndicator = false
result.showsHorizontalScrollIndicator = false
result.register(view: SessionAvatarCell.self)
result.register(view: SessionCell.self)
result.registerHeaderFooterView(view: SessionHeaderView.self)
result.dataSource = self
@ -45,11 +48,50 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
return result
}()
private lazy var emptyStateLabel: UILabel = {
let result: UILabel = UILabel()
result.translatesAutoresizingMaskIntoConstraints = false
result.isUserInteractionEnabled = false
result.font = .systemFont(ofSize: Values.smallFontSize)
result.themeTextColor = .textSecondary
result.textAlignment = .center
result.numberOfLines = 0
result.isHidden = true
return result
}()
private lazy var fadeView: GradientView = {
let result: GradientView = GradientView()
result.themeBackgroundGradient = [
.value(.backgroundPrimary, alpha: 0), // Want this to take up 20% (~25pt)
.backgroundPrimary,
.backgroundPrimary,
.backgroundPrimary,
.backgroundPrimary
]
result.set(.height, to: Values.footerGradientHeight(window: UIApplication.shared.keyWindow))
result.isHidden = true
return result
}()
private lazy var footerButton: SessionButton = {
let result: SessionButton = SessionButton(style: .bordered, size: .medium)
result.translatesAutoresizingMaskIntoConstraints = false
result.addTarget(self, action: #selector(footerButtonTapped), for: .touchUpInside)
result.isHidden = true
return result
}()
// MARK: - Initialization
init(viewModel: SessionTableViewModel<NavItemId, Section, SettingItem>) {
self.viewModel = viewModel
Storage.shared.addObserver(viewModel.pagedDataObserver)
super.init(nibName: nil, bundle: nil)
}
@ -74,6 +116,9 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
view.themeBackgroundColor = .backgroundPrimary
view.addSubview(tableView)
view.addSubview(emptyStateLabel)
view.addSubview(fadeView)
view.addSubview(footerButton)
setupLayout()
setupBinding()
@ -98,6 +143,13 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
startObservingChanges()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
viewHasAppeared = true
autoLoadNextPageIfNeeded()
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
@ -114,18 +166,29 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
private func setupLayout() {
tableView.pin(to: view)
emptyStateLabel.pin(.top, to: .top, of: self.view, withInset: Values.massiveSpacing)
emptyStateLabel.pin(.leading, to: .leading, of: self.view, withInset: Values.mediumSpacing)
emptyStateLabel.pin(.trailing, to: .trailing, of: self.view, withInset: -Values.mediumSpacing)
fadeView.pin(.leading, to: .leading, of: self.view)
fadeView.pin(.trailing, to: .trailing, of: self.view)
fadeView.pin(.bottom, to: .bottom, of: self.view)
footerButton.center(.horizontal, in: self.view)
footerButton.pin(.bottom, to: .bottom, of: self.view.safeAreaLayoutGuide, withInset: -Values.smallSpacing)
}
// MARK: - Updating
private func startObservingChanges() {
// Start observing for data changes
dataChangeCancellable = viewModel.observableSettingsData
dataChangeCancellable = viewModel.observableTableData
.receiveOnMain(
// If we haven't done the initial load the trigger it immediately (blocking the main
// thread so we remain on the launch screen until it completes to be consistent with
// the old behaviour)
immediately: !hasLoadedInitialSettingsData
immediately: !hasLoadedInitialTableData
)
.sink(
receiveCompletion: { [weak self] result in
@ -146,9 +209,9 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
case .finished: break
}
},
receiveValue: { [weak self] settingsData in
receiveValue: { [weak self] updatedData, changeset in
self?.dataStreamJustFailed = false
self?.handleSettingsUpdates(settingsData)
self?.handleDataUpdates(updatedData, changeset: changeset)
}
)
}
@ -158,27 +221,80 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
dataChangeCancellable?.cancel()
}
private func handleSettingsUpdates(_ updatedData: [SectionModel], initialLoad: Bool = false) {
private func handleDataUpdates(
_ updatedData: [SectionModel],
changeset: StagedChangeset<[SectionModel]>,
initialLoad: Bool = false
) {
// Ensure the first load runs without animations (if we don't do this the cells will animate
// in from a frame of CGRect.zero)
guard hasLoadedInitialSettingsData else {
hasLoadedInitialSettingsData = true
UIView.performWithoutAnimation { handleSettingsUpdates(updatedData, initialLoad: true) }
guard hasLoadedInitialTableData else {
hasLoadedInitialTableData = true
UIView.performWithoutAnimation {
handleDataUpdates(updatedData, changeset: changeset, initialLoad: true)
}
return
}
// Show the empty state if there is no data
let itemCount: Int = updatedData
.map { $0.elements.count }
.reduce(0, +)
emptyStateLabel.isHidden = (itemCount > 0)
CATransaction.begin()
CATransaction.setCompletionBlock { [weak self] in
// Complete page loading
self?.isLoadingMore = false
self?.autoLoadNextPageIfNeeded()
}
// Reload the table content (animate changes after the first load)
tableView.reload(
using: StagedChangeset(source: viewModel.settingsData, target: updatedData),
using: changeset,
deleteSectionsAnimation: .none,
insertSectionsAnimation: .none,
reloadSectionsAnimation: .none,
deleteRowsAnimation: .bottom,
insertRowsAnimation: .none,
reloadRowsAnimation: .none,
deleteRowsAnimation: .fade,
insertRowsAnimation: .fade,
reloadRowsAnimation: .fade,
interrupt: { $0.changeCount > 100 } // Prevent too many changes from causing performance issues
) { [weak self] updatedData in
self?.viewModel.updateSettings(updatedData)
self?.viewModel.updateTableData(updatedData)
}
CATransaction.commit()
}
private func autoLoadNextPageIfNeeded() {
guard !self.isAutoLoadingNextPage && !self.isLoadingMore else { return }
self.isAutoLoadingNextPage = true
DispatchQueue.main.asyncAfter(deadline: .now() + PagedData.autoLoadNextPageDelay) { [weak self] in
self?.isAutoLoadingNextPage = false
// Note: We sort the headers as we want to prioritise loading newer pages over older ones
let sections: [(Section, CGRect)] = (self?.viewModel.tableData
.enumerated()
.map { index, section in
(section.model, (self?.tableView.rectForHeader(inSection: index) ?? .zero))
})
.defaulting(to: [])
let shouldLoadMore: Bool = sections
.contains { section, headerRect in
section.style == .loadMore &&
headerRect != .zero &&
(self?.tableView.bounds.contains(headerRect) == true)
}
guard shouldLoadMore else { return }
self?.isLoadingMore = true
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
self?.viewModel.loadPageAfter()
}
}
}
@ -188,18 +304,27 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
viewModel.isEditing
.receive(on: DispatchQueue.main)
.sink { [weak self] isEditing in
self?.setEditing(isEditing, animated: true)
self?.tableView.visibleCells.forEach { cell in
switch cell {
case let cell as SessionCell:
cell.update(isEditing: isEditing, animated: true)
case let avatarCell as SessionAvatarCell:
avatarCell.update(isEditing: isEditing, animated: true)
default: break
}
UIView.animate(withDuration: 0.25) {
self?.setEditing(isEditing, animated: true)
self?.tableView.visibleCells
.compactMap { $0 as? SessionCell }
.filter { $0.interactionMode == .editable || $0.interactionMode == .alwaysEditing }
.enumerated()
.forEach { index, cell in
cell.update(
isEditing: (isEditing || cell.interactionMode == .alwaysEditing),
becomeFirstResponder: (
isEditing &&
index == 0 &&
cell.interactionMode != .alwaysEditing
),
animated: true
)
}
self?.tableView.beginUpdates()
self?.tableView.endUpdates()
}
}
.store(in: &disposables)
@ -248,6 +373,13 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
}
.store(in: &disposables)
viewModel.emptyStateTextPublisher
.receiveOnMain(immediately: true)
.sink { [weak self] text in
self?.emptyStateLabel.text = text
}
.store(in: &disposables)
viewModel.footerView
.receiveOnMain(immediately: true)
.sink { [weak self] footerView in
@ -255,6 +387,33 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
}
.store(in: &disposables)
viewModel.footerButtonInfo
.receiveOnMain(immediately: true)
.sink { [weak self] buttonInfo in
if let buttonInfo: SessionButton.Info = buttonInfo {
self?.footerButton.setTitle(buttonInfo.title, for: .normal)
self?.footerButton.setStyle(buttonInfo.style)
self?.footerButton.isEnabled = buttonInfo.isEnabled
}
self?.onFooterTap = buttonInfo?.onTap
self?.fadeView.isHidden = (buttonInfo == nil)
self?.footerButton.isHidden = (buttonInfo == nil)
// If we have a footerButton then we want to manually control the contentInset
self?.tableView.contentInsetAdjustmentBehavior = (buttonInfo == nil ? .automatic : .never)
self?.tableView.contentInset = UIEdgeInsets(
top: 0,
left: 0,
bottom: (buttonInfo == nil ?
0 :
Values.footerGradientHeight(window: UIApplication.shared.keyWindow)
),
right: 0
)
}
.store(in: &disposables)
viewModel.showToast
.receive(on: DispatchQueue.main)
.sink { [weak self] text, color in
@ -303,91 +462,60 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
case .dismiss: self?.dismiss(animated: true)
case .pop: self?.navigationController?.popViewController(animated: true)
case .popToRoot: self?.navigationController?.popToRootViewController(animated: true)
}
}
.store(in: &disposables)
}
@objc private func footerButtonTapped() {
onFooterTap?()
}
// MARK: - UITableViewDataSource
func numberOfSections(in tableView: UITableView) -> Int {
return self.viewModel.settingsData.count
return self.viewModel.tableData.count
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return self.viewModel.settingsData[section].elements.count
return self.viewModel.tableData[section].elements.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let section: SectionModel = viewModel.settingsData[indexPath.section]
let section: SectionModel = viewModel.tableData[indexPath.section]
let info: SessionCell.Info<SettingItem> = section.elements[indexPath.row]
let cell: SessionCell = tableView.dequeue(type: SessionCell.self, for: indexPath)
cell.update(with: info)
cell.update(
isEditing: (self.isEditing || (info.title?.interaction == .alwaysEditing)),
becomeFirstResponder: false,
animated: false
)
cell.textPublisher
.sink(receiveValue: { [weak self] text in
self?.viewModel.textChanged(text, for: info.id)
})
.store(in: &cell.disposables)
switch info.leftAccessory {
case .threadInfo(let threadViewModel, let style, let avatarTapped, let titleTapped, let titleChanged):
let cell: SessionAvatarCell = tableView.dequeue(type: SessionAvatarCell.self, for: indexPath)
cell.update(
threadViewModel: threadViewModel,
style: style,
viewController: self
)
cell.update(isEditing: self.isEditing, animated: false)
cell.profilePictureTapPublisher
.filter { _ in threadViewModel.threadVariant == .contact }
.sink(receiveValue: { _ in avatarTapped?() })
.store(in: &cell.disposables)
cell.displayNameTapPublisher
.filter { _ in threadViewModel.threadVariant == .contact }
.sink(receiveValue: { _ in titleTapped?() })
.store(in: &cell.disposables)
cell.textPublisher
.sink(receiveValue: { text in titleChanged?(text) })
.store(in: &cell.disposables)
return cell
default:
let cell: SessionCell = tableView.dequeue(type: SessionCell.self, for: indexPath)
cell.update(
with: info,
style: .rounded,
position: Position.with(indexPath.row, count: section.elements.count)
)
cell.update(isEditing: self.isEditing, animated: false)
return cell
}
return cell
}
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
let section: SectionModel = viewModel.settingsData[section]
let section: SectionModel = viewModel.tableData[section]
let result: SessionHeaderView = tableView.dequeueHeaderFooterView(type: SessionHeaderView.self)
result.update(
title: section.model.title,
style: section.model.style
)
switch section.model.style {
case .none:
return UIView()
case .padding, .title:
let result: SessionHeaderView = tableView.dequeueHeaderFooterView(type: SessionHeaderView.self)
result.update(
title: section.model.title,
hasSeparator: (section.elements.first?.shouldHaveBackground != false)
)
return result
}
return result
}
// MARK: - UITableViewDelegate
func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
let section: SectionModel = viewModel.settingsData[section]
switch section.model.style {
case .none: return 0
case .padding, .title: return UITableView.automaticDimension
}
return viewModel.tableData[section].model.style.height
}
func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
@ -397,11 +525,28 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
return UITableView.automaticDimension
}
func tableView(_ tableView: UITableView, willDisplayHeaderView view: UIView, forSection section: Int) {
guard self.hasLoadedInitialTableData && self.viewHasAppeared && !self.isLoadingMore else { return }
let section: SectionModel = self.viewModel.tableData[section]
switch section.model.style {
case .loadMore:
self.isLoadingMore = true
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
self?.viewModel.loadPageAfter()
}
default: break
}
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
let section: SectionModel = self.viewModel.settingsData[indexPath.section]
let section: SectionModel = self.viewModel.tableData[indexPath.section]
let info: SessionCell.Info<SettingItem> = section.elements[indexPath.row]
// Do nothing if the item is disabled
@ -414,10 +559,10 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
}
switch (info.leftAccessory, info.rightAccessory) {
case (_, .highlightingBackgroundLabel(_)):
case (_, .highlightingBackgroundLabel(_, _)):
return (!cell.rightAccessoryView.isHidden ? cell.rightAccessoryView : cell)
case (.highlightingBackgroundLabel(_), _):
case (.highlightingBackgroundLabel(_, _), _):
return (!cell.leftAccessoryView.isHidden ? cell.leftAccessoryView : cell)
default:
@ -428,14 +573,15 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
.enumerated()
.first(where: { index, info in
switch (info.leftAccessory, info.rightAccessory) {
case (_, .radio(_, let isSelected, _)): return isSelected()
case (.radio(_, let isSelected, _), _): return isSelected()
case (_, .radio(_, let isSelected, _, _)): return isSelected()
case (.radio(_, let isSelected, _, _), _): return isSelected()
default: return false
}
})
let performAction: () -> Void = { [weak self, weak tappedView] in
info.onTap?(tappedView)
info.onTap?()
info.onTapView?(tappedView)
self?.manuallyReload(indexPath: indexPath, section: section, info: info)
// Update the old selection as well
@ -463,10 +609,6 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
let confirmationModal: ConfirmationModal = ConfirmationModal(
targetView: tappedView,
info: confirmationInfo
.with(onConfirm: { [weak self] _ in
performAction()
self?.dismiss(animated: true)
})
)
present(confirmationModal, animated: true, completion: nil)
}
@ -478,11 +620,7 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
) {
// Try update the existing cell to have a nice animation instead of reloading the cell
if let existingCell: SessionCell = tableView.cellForRow(at: indexPath) as? SessionCell {
existingCell.update(
with: info,
style: .rounded,
position: Position.with(indexPath.row, count: section.elements.count)
)
existingCell.update(with: info)
}
else {
tableView.reloadRows(at: [indexPath], with: .none)

View File

@ -10,7 +10,7 @@ import SessionUtilitiesKit
class SessionTableViewModel<NavItemId: Equatable, Section: SessionTableSection, SettingItem: Hashable & Differentiable> {
typealias SectionModel = ArraySection<Section, SessionCell.Info<SettingItem>>
typealias ObservableData = AnyPublisher<[SectionModel], Error>
typealias ObservableData = AnyPublisher<([SectionModel], StagedChangeset<[SectionModel]>), Error>
// MARK: - Input
@ -18,6 +18,9 @@ class SessionTableViewModel<NavItemId: Equatable, Section: SessionTableSection,
lazy var isEditing: AnyPublisher<Bool, Never> = _isEditing
.removeDuplicates()
.shareReplay(1)
private let _textChanged: PassthroughSubject<(text: String?, item: SettingItem), Never> = PassthroughSubject()
lazy var textChanged: AnyPublisher<(text: String?, item: SettingItem), Never> = _textChanged
.eraseToAnyPublisher()
// MARK: - Navigation
@ -37,15 +40,25 @@ class SessionTableViewModel<NavItemId: Equatable, Section: SessionTableSection,
// MARK: - Content
open var title: String { preconditionFailure("abstract class - override in subclass") }
open var settingsData: [SectionModel] { preconditionFailure("abstract class - override in subclass") }
open var observableSettingsData: ObservableData {
preconditionFailure("abstract class - override in subclass")
}
open var footerView: AnyPublisher<UIView?, Never> { Just(nil).eraseToAnyPublisher() }
open var emptyStateTextPublisher: AnyPublisher<String?, Never> { Just(nil).eraseToAnyPublisher() }
func updateSettings(_ updatedSettings: [SectionModel]) {
fileprivate var hasEmittedInitialData: Bool = false
public private(set) var tableData: [SectionModel] = []
open var observableTableData: ObservableData {
preconditionFailure("abstract class - override in subclass")
}
open var pagedDataObserver: TransactionObserver? { nil }
open var footerView: AnyPublisher<UIView?, Never> { Just(nil).eraseToAnyPublisher() }
open var footerButtonInfo: AnyPublisher<SessionButton.Info?, Never> {
Just(nil).eraseToAnyPublisher()
}
func updateTableData(_ updatedData: [SectionModel]) {
self.tableData = updatedData
}
func loadPageBefore() { preconditionFailure("abstract class - override in subclass") }
func loadPageAfter() { preconditionFailure("abstract class - override in subclass") }
// MARK: - Functions
@ -53,6 +66,10 @@ class SessionTableViewModel<NavItemId: Equatable, Section: SessionTableSection,
_isEditing.send(isEditing)
}
func textChanged(_ text: String?, for item: SettingItem) {
_textChanged.send((text, item))
}
func showToast(text: String, backgroundColor: ThemeValue = .backgroundPrimary) {
_showToast.send((text, backgroundColor))
}
@ -65,3 +82,49 @@ class SessionTableViewModel<NavItemId: Equatable, Section: SessionTableSection,
_transitionToScreen.send((viewController, transitionType))
}
}
// MARK: - Convenience
extension Array {
func mapToSessionTableViewData<Nav, Section, Item>(
for viewModel: SessionTableViewModel<Nav, Section, Item>?
) -> [ArraySection<Section, SessionCell.Info<Item>>] where Element == ArraySection<Section, SessionCell.Info<Item>> {
// Update the data to include the proper position for each element
return self.map { section in
ArraySection(
model: section.model,
elements: section.elements.enumerated().map { index, element in
element.updatedPosition(for: index, count: section.elements.count)
}
)
}
}
}
extension AnyPublisher {
func mapToSessionTableViewData<Nav, Section, Item>(
for viewModel: SessionTableViewModel<Nav, Section, Item>
) -> AnyPublisher<(Output, StagedChangeset<Output>), Failure> where Output == [ArraySection<Section, SessionCell.Info<Item>>] {
return self
.map { [weak viewModel] updatedData -> (Output, StagedChangeset<Output>) in
let updatedDataWithPositions: Output = updatedData
.mapToSessionTableViewData(for: viewModel)
// Generate an updated changeset
let changeset = StagedChangeset(
source: (viewModel?.tableData ?? []),
target: updatedDataWithPositions
)
return (updatedDataWithPositions, changeset)
}
.filter { [weak viewModel] _, changeset in
viewModel?.hasEmittedInitialData == false || // Always emit at least once
!changeset.isEmpty // Do nothing if there were no changes
}
.handleEvents(receiveOutput: { [weak viewModel] _ in
viewModel?.hasEmittedInitialData = true
})
.eraseToAnyPublisher()
}
}

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)
case pop
/// This will only trigger a `popToRootViewController` call (if the screen was presented it'll do nothing)
case popToRoot
/// This will only trigger a `dismiss` call (if the screen was pushed to a presented navigation controller it'll dismiss
/// the navigation controller, otherwise this will do nothing)
case dismiss

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?,
size: IconSize,
customTint: ThemeValue?,
shouldFill: Bool
shouldFill: Bool,
accessibility: SessionCell.Accessibility?
)
case iconAsync(
size: IconSize,
customTint: ThemeValue?,
shouldFill: Bool,
accessibility: SessionCell.Accessibility?,
setter: (UIImageView) -> Void
)
case toggle(DataSource)
case dropDown(DataSource)
case toggle(
DataSource,
accessibility: SessionCell.Accessibility?
)
case dropDown(
DataSource,
accessibility: SessionCell.Accessibility?
)
case radio(
size: RadioSize,
isSelected: () -> Bool,
storedSelection: Bool
storedSelection: Bool,
accessibility: SessionCell.Accessibility?
)
case highlightingBackgroundLabel(title: String)
case profile(String, Profile?)
case customView(viewGenerator: () -> UIView)
case threadInfo(
threadViewModel: SessionThreadViewModel,
style: ThreadInfoStyle = ThreadInfoStyle(),
avatarTapped: (() -> Void)? = nil,
titleTapped: (() -> Void)? = nil,
titleChanged: ((String) -> Void)? = nil
case highlightingBackgroundLabel(
title: String,
accessibility: SessionCell.Accessibility?
)
case profile(
id: String,
size: IconSize,
threadVariant: SessionThread.Variant,
customImageData: Data?,
profile: Profile?,
additionalProfile: Profile?,
cornerIcon: UIImage?,
accessibility: SessionCell.Accessibility?
)
case search(
placeholder: String,
accessibility: SessionCell.Accessibility?,
searchTermChanged: (String?) -> Void
)
case button(
style: SessionButton.Style,
title: String,
accessibility: SessionCell.Accessibility?,
run: (SessionButton?) -> Void
)
case customView(
hashValue: AnyHashable,
viewGenerator: () -> UIView
)
// MARK: - Convenience Vatiables
var shouldFitToEdge: Bool {
switch self {
case .icon(_, _, _, let shouldFill), .iconAsync(_, _, let shouldFill, _): return shouldFill
case .icon(_, _, _, let shouldFill, _), .iconAsync(_, _, let shouldFill, _, _):
return shouldFill
default: return false
}
}
var currentBoolValue: Bool {
switch self {
case .toggle(let dataSource), .dropDown(let dataSource): return dataSource.currentBoolValue
case .toggle(let dataSource, _), .dropDown(let dataSource, _): return dataSource.currentBoolValue
case .radio(_, let isSelected, _, _): return isSelected()
default: return false
}
}
@ -59,90 +90,166 @@ extension SessionCell {
public func hash(into hasher: inout Hasher) {
switch self {
case .icon(let image, let size, let customTint, let shouldFill):
case .icon(let image, let size, let customTint, let shouldFill, let accessibility):
image.hash(into: &hasher)
size.hash(into: &hasher)
customTint.hash(into: &hasher)
shouldFill.hash(into: &hasher)
accessibility.hash(into: &hasher)
case .iconAsync(let size, let customTint, let shouldFill, _):
case .iconAsync(let size, let customTint, let shouldFill, let accessibility, _):
size.hash(into: &hasher)
customTint.hash(into: &hasher)
shouldFill.hash(into: &hasher)
accessibility.hash(into: &hasher)
case .toggle(let dataSource):
case .toggle(let dataSource, let accessibility):
dataSource.hash(into: &hasher)
accessibility.hash(into: &hasher)
case .dropDown(let dataSource):
case .dropDown(let dataSource, let accessibility):
dataSource.hash(into: &hasher)
accessibility.hash(into: &hasher)
case .radio(let size, let isSelected, let storedSelection):
case .radio(let size, let isSelected, let storedSelection, let accessibility):
size.hash(into: &hasher)
isSelected().hash(into: &hasher)
storedSelection.hash(into: &hasher)
accessibility.hash(into: &hasher)
case .highlightingBackgroundLabel(let title):
case .highlightingBackgroundLabel(let title, let accessibility):
title.hash(into: &hasher)
accessibility.hash(into: &hasher)
case .profile(let profileId, let profile):
case .profile(
let profileId,
let size,
let threadVariant,
let customImageData,
let profile,
let additionalProfile,
let cornerIcon,
let accessibility
):
profileId.hash(into: &hasher)
size.hash(into: &hasher)
threadVariant.hash(into: &hasher)
customImageData.hash(into: &hasher)
profile.hash(into: &hasher)
additionalProfile.hash(into: &hasher)
cornerIcon.hash(into: &hasher)
accessibility.hash(into: &hasher)
case .customView: break
case .threadInfo(let threadViewModel, let style, _, _, _):
threadViewModel.hash(into: &hasher)
case .search(let placeholder, let accessibility, _):
placeholder.hash(into: &hasher)
accessibility.hash(into: &hasher)
case .button(let style, let title, let accessibility, _):
style.hash(into: &hasher)
title.hash(into: &hasher)
accessibility.hash(into: &hasher)
case .customView(let hashValue, _):
hashValue.hash(into: &hasher)
}
}
public static func == (lhs: Accessory, rhs: Accessory) -> Bool {
switch (lhs, rhs) {
case (.icon(let lhsImage, let lhsSize, let lhsCustomTint, let lhsShouldFill), .icon(let rhsImage, let rhsSize, let rhsCustomTint, let rhsShouldFill)):
case (.icon(let lhsImage, let lhsSize, let lhsCustomTint, let lhsShouldFill, let lhsAccessibility), .icon(let rhsImage, let rhsSize, let rhsCustomTint, let rhsShouldFill, let rhsAccessibility)):
return (
lhsImage == rhsImage &&
lhsSize == rhsSize &&
lhsCustomTint == rhsCustomTint &&
lhsShouldFill == rhsShouldFill
lhsShouldFill == rhsShouldFill &&
lhsAccessibility == rhsAccessibility
)
case (.iconAsync(let lhsSize, let lhsCustomTint, let lhsShouldFill, _), .iconAsync(let rhsSize, let rhsCustomTint, let rhsShouldFill, _)):
case (.iconAsync(let lhsSize, let lhsCustomTint, let lhsShouldFill, let lhsAccessibility, _), .iconAsync(let rhsSize, let rhsCustomTint, let rhsShouldFill, let rhsAccessibility, _)):
return (
lhsSize == rhsSize &&
lhsCustomTint == rhsCustomTint &&
lhsShouldFill == rhsShouldFill
lhsShouldFill == rhsShouldFill &&
lhsAccessibility == rhsAccessibility
)
case (.toggle(let lhsDataSource), .toggle(let rhsDataSource)):
return (lhsDataSource == rhsDataSource)
case (.toggle(let lhsDataSource, let lhsAccessibility), .toggle(let rhsDataSource, let rhsAccessibility)):
return (
lhsDataSource == rhsDataSource &&
lhsAccessibility == rhsAccessibility
)
case (.dropDown(let lhsDataSource), .dropDown(let rhsDataSource)):
return (lhsDataSource == rhsDataSource)
case (.dropDown(let lhsDataSource, let lhsAccessibility), .dropDown(let rhsDataSource, let rhsAccessibility)):
return (
lhsDataSource == rhsDataSource &&
lhsAccessibility == rhsAccessibility
)
case (.radio(let lhsSize, let lhsIsSelected, let lhsStoredSelection), .radio(let rhsSize, let rhsIsSelected, let rhsStoredSelection)):
case (.radio(let lhsSize, let lhsIsSelected, let lhsStoredSelection, let lhsAccessibility), .radio(let rhsSize, let rhsIsSelected, let rhsStoredSelection, let rhsAccessibility)):
return (
lhsSize == rhsSize &&
lhsIsSelected() == rhsIsSelected() &&
lhsStoredSelection == rhsStoredSelection
lhsStoredSelection == rhsStoredSelection &&
lhsAccessibility == rhsAccessibility
)
case (.highlightingBackgroundLabel(let lhsTitle), .highlightingBackgroundLabel(let rhsTitle)):
return (lhsTitle == rhsTitle)
case (.highlightingBackgroundLabel(let lhsTitle, let lhsAccessibility), .highlightingBackgroundLabel(let rhsTitle, let rhsAccessibility)):
return (
lhsTitle == rhsTitle &&
lhsAccessibility == rhsAccessibility
)
case (.profile(let lhsProfileId, let lhsProfile), .profile(let rhsProfileId, let rhsProfile)):
case (
.profile(
let lhsProfileId,
let lhsSize,
let lhsThreadVariant,
let lhsProfile,
let lhsAdditionalProfile,
let lhsCustomImageData,
let lhsCornerIcon,
let lhsAccessibility
),
.profile(
let rhsProfileId,
let rhsSize,
let rhsThreadVariant,
let rhsProfile,
let rhsAdditionalProfile,
let rhsCustomImageData,
let rhsCornerIcon,
let rhsAccessibility
)
):
return (
lhsProfileId == rhsProfileId &&
lhsProfile == rhsProfile
lhsSize == rhsSize &&
lhsThreadVariant == rhsThreadVariant &&
lhsProfile == rhsProfile &&
lhsAdditionalProfile == rhsAdditionalProfile &&
lhsCustomImageData == rhsCustomImageData &&
lhsCornerIcon == rhsCornerIcon &&
lhsAccessibility == rhsAccessibility
)
case (.customView, .customView): return false
case (.threadInfo(let lhsThreadViewModel, let lhsStyle, _, _, _), .threadInfo(let rhsThreadViewModel, let rhsStyle, _, _, _)):
case (.search(let lhsPlaceholder, let lhsAccessibility, _), .search(let rhsPlaceholder, let rhsAccessibility, _)):
return (
lhsThreadViewModel == rhsThreadViewModel &&
lhsStyle == rhsStyle
lhsPlaceholder == rhsPlaceholder &&
lhsAccessibility == rhsAccessibility
)
case (.button(let lhsStyle, let lhsTitle, let lhsAccessibility, _), .button(let rhsStyle, let rhsTitle, let rhsAccessibility, _)):
return (
lhsStyle == rhsStyle &&
lhsTitle == rhsTitle &&
lhsAccessibility == rhsAccessibility
)
case (.customView(let lhsHashValue, _), .customView(let rhsHashValue, _)):
return (
lhsHashValue.hashValue == rhsHashValue.hashValue
)
default: return false
}
}
@ -157,59 +264,121 @@ extension SessionCell.Accessory {
// MARK: - .icon Variants
public static func icon(_ image: UIImage?) -> SessionCell.Accessory {
return .icon(image, size: .medium, customTint: nil, shouldFill: false)
return .icon(image, size: .medium, customTint: nil, shouldFill: false, accessibility: nil)
}
public static func icon(_ image: UIImage?, customTint: ThemeValue) -> SessionCell.Accessory {
return .icon(image, size: .medium, customTint: customTint, shouldFill: false)
return .icon(image, size: .medium, customTint: customTint, shouldFill: false, accessibility: nil)
}
public static func icon(_ image: UIImage?, size: IconSize) -> SessionCell.Accessory {
return .icon(image, size: size, customTint: nil, shouldFill: false)
return .icon(image, size: size, customTint: nil, shouldFill: false, accessibility: nil)
}
public static func icon(_ image: UIImage?, size: IconSize, customTint: ThemeValue) -> SessionCell.Accessory {
return .icon(image, size: size, customTint: customTint, shouldFill: false)
return .icon(image, size: size, customTint: customTint, shouldFill: false, accessibility: nil)
}
public static func icon(_ image: UIImage?, shouldFill: Bool) -> SessionCell.Accessory {
return .icon(image, size: .medium, customTint: nil, shouldFill: shouldFill)
return .icon(image, size: .medium, customTint: nil, shouldFill: shouldFill, accessibility: nil)
}
public static func icon(_ image: UIImage?, accessibility: SessionCell.Accessibility) -> SessionCell.Accessory {
return .icon(image, size: .medium, customTint: nil, shouldFill: false, accessibility: accessibility)
}
// MARK: - .iconAsync Variants
public static func iconAsync(_ setter: @escaping (UIImageView) -> Void) -> SessionCell.Accessory {
return .iconAsync(size: .medium, customTint: nil, shouldFill: false, setter: setter)
return .iconAsync(size: .medium, customTint: nil, shouldFill: false, accessibility: nil, setter: setter)
}
public static func iconAsync(customTint: ThemeValue, _ setter: @escaping (UIImageView) -> Void) -> SessionCell.Accessory {
return .iconAsync(size: .medium, customTint: customTint, shouldFill: false, setter: setter)
return .iconAsync(size: .medium, customTint: customTint, shouldFill: false, accessibility: nil, setter: setter)
}
public static func iconAsync(size: IconSize, setter: @escaping (UIImageView) -> Void) -> SessionCell.Accessory {
return .iconAsync(size: size, customTint: nil, shouldFill: false, setter: setter)
return .iconAsync(size: size, customTint: nil, shouldFill: false, accessibility: nil, setter: setter)
}
public static func iconAsync(shouldFill: Bool, setter: @escaping (UIImageView) -> Void) -> SessionCell.Accessory {
return .iconAsync(size: .medium, customTint: nil, shouldFill: shouldFill, setter: setter)
return .iconAsync(size: .medium, customTint: nil, shouldFill: shouldFill, accessibility: nil, setter: setter)
}
public static func iconAsync(size: IconSize, customTint: ThemeValue, setter: @escaping (UIImageView) -> Void) -> SessionCell.Accessory {
return .iconAsync(size: size, customTint: customTint, shouldFill: false, setter: setter)
return .iconAsync(size: size, customTint: customTint, shouldFill: false, accessibility: nil, setter: setter)
}
public static func iconAsync(size: IconSize, shouldFill: Bool, setter: @escaping (UIImageView) -> Void) -> SessionCell.Accessory {
return .iconAsync(size: size, customTint: nil, shouldFill: shouldFill, setter: setter)
return .iconAsync(size: size, customTint: nil, shouldFill: shouldFill, accessibility: nil, setter: setter)
}
// MARK: - .toggle Variants
public static func toggle(_ dataSource: DataSource) -> SessionCell.Accessory {
return .toggle(dataSource, accessibility: nil)
}
// MARK: - .dropDown Variants
public static func dropDown(_ dataSource: DataSource) -> SessionCell.Accessory {
return .dropDown(dataSource, accessibility: nil)
}
// MARK: - .radio Variants
public static func radio(isSelected: @escaping () -> Bool) -> SessionCell.Accessory {
return .radio(size: .medium, isSelected: isSelected, storedSelection: false)
return .radio(size: .medium, isSelected: isSelected, storedSelection: false, accessibility: nil)
}
public static func radio(isSelected: @escaping () -> Bool, storedSelection: Bool) -> SessionCell.Accessory {
return .radio(size: .medium, isSelected: isSelected, storedSelection: storedSelection)
return .radio(size: .medium, isSelected: isSelected, storedSelection: storedSelection, accessibility: nil)
}
// MARK: - .highlightingBackgroundLabel Variants
public static func highlightingBackgroundLabel(title: String) -> SessionCell.Accessory {
return .highlightingBackgroundLabel(title: title, accessibility: nil)
}
// MARK: - .profile Variants
public static func profile(id: String, profile: Profile?) -> SessionCell.Accessory {
return .profile(
id: id,
size: .veryLarge,
threadVariant: .contact,
customImageData: nil,
profile: profile,
additionalProfile: nil,
cornerIcon: nil,
accessibility: nil
)
}
public static func profile(id: String, size: IconSize, profile: Profile?) -> SessionCell.Accessory {
return .profile(
id: id,
size: size,
threadVariant: .contact,
customImageData: nil,
profile: profile,
additionalProfile: nil,
cornerIcon: nil,
accessibility: nil
)
}
// MARK: - .search Variants
public static func search(placeholder: String, searchTermChanged: @escaping (String?) -> Void) -> SessionCell.Accessory {
return .search(placeholder: placeholder, accessibility: nil, searchTermChanged: searchTermChanged)
}
// MARK: - .button Variants
public static func button(style: SessionButton.Style, title: String, run: @escaping (SessionButton?) -> Void) -> SessionCell.Accessory {
return .button(style: style, title: title, accessibility: nil, run: run)
}
}
@ -293,42 +462,3 @@ extension SessionCell.Accessory {
}
}
}
// MARK: - SessionCell.Accessory.ThreadInfoStyle
extension SessionCell.Accessory {
public struct ThreadInfoStyle: Hashable, Equatable {
public enum Style: Hashable, Equatable {
case small
case monoSmall
case monoLarge
}
public struct Action: Hashable, Equatable {
let title: String
let run: (SessionButton?) -> ()
public func hash(into hasher: inout Hasher) {
title.hash(into: &hasher)
}
public static func == (lhs: Action, rhs: Action) -> Bool {
return (lhs.title == rhs.title)
}
}
public let separatorTitle: String?
public let descriptionStyle: Style
public let descriptionActions: [Action]
public init(
separatorTitle: String? = nil,
descriptionStyle: Style = .monoSmall,
descriptionActions: [Action] = []
) {
self.separatorTitle = separatorTitle
self.descriptionStyle = descriptionStyle
self.descriptionActions = descriptionActions
}
}
}

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 {
public struct Info<ID: Hashable & Differentiable>: Equatable, Hashable, Differentiable {
let id: ID
let position: Position
let leftAccessory: SessionCell.Accessory?
let title: String
let subtitle: String?
let subtitleExtraViewGenerator: (() -> UIView)?
let tintColor: ThemeValue
let title: TextInfo?
let subtitle: TextInfo?
let rightAccessory: SessionCell.Accessory?
let extraAction: SessionCell.ExtraAction?
let styling: StyleInfo
let isEnabled: Bool
let shouldHaveBackground: Bool
let accessibilityIdentifier: String?
let accessibilityLabel: String?
let leftAccessoryAccessibilityLabel: String?
let rightAccessoryAccessibilityLabel: String?
let accessibility: SessionCell.Accessibility?
let confirmationInfo: ConfirmationModal.Info?
let onTap: ((UIView?) -> Void)?
let onTap: (() -> Void)?
let onTapView: ((UIView?) -> Void)?
var currentBoolValue: Bool {
return (
@ -34,74 +30,30 @@ extension SessionCell {
init(
id: ID,
position: Position = .individual,
leftAccessory: SessionCell.Accessory? = nil,
title: String,
subtitle: String? = nil,
subtitleExtraViewGenerator: (() -> UIView)? = nil,
tintColor: ThemeValue = .textPrimary,
title: SessionCell.TextInfo? = nil,
subtitle: SessionCell.TextInfo? = nil,
rightAccessory: SessionCell.Accessory? = nil,
extraAction: SessionCell.ExtraAction? = nil,
styling: StyleInfo = StyleInfo(),
isEnabled: Bool = true,
shouldHaveBackground: Bool = true,
accessibilityIdentifier: String? = nil,
accessibilityLabel: String? = nil,
leftAccessoryAccessibilityLabel: String? = nil,
rightAccessoryAccessibilityLabel: String? = nil,
accessibility: SessionCell.Accessibility? = nil,
confirmationInfo: ConfirmationModal.Info? = nil,
onTap: ((UIView?) -> Void)?
onTap: (() -> Void)? = nil,
onTapView: ((UIView?) -> Void)? = nil
) {
self.id = id
self.position = position
self.leftAccessory = leftAccessory
self.title = title
self.subtitle = subtitle
self.subtitleExtraViewGenerator = subtitleExtraViewGenerator
self.tintColor = tintColor
self.rightAccessory = rightAccessory
self.extraAction = extraAction
self.styling = styling
self.isEnabled = isEnabled
self.shouldHaveBackground = shouldHaveBackground
self.accessibilityIdentifier = accessibilityIdentifier
self.accessibilityLabel = accessibilityLabel
self.leftAccessoryAccessibilityLabel = leftAccessoryAccessibilityLabel
self.rightAccessoryAccessibilityLabel = rightAccessoryAccessibilityLabel
self.accessibility = accessibility
self.confirmationInfo = confirmationInfo
self.onTap = onTap
}
init(
id: ID,
leftAccessory: SessionCell.Accessory? = nil,
title: String,
subtitle: String? = nil,
subtitleExtraViewGenerator: (() -> UIView)? = nil,
tintColor: ThemeValue = .textPrimary,
rightAccessory: SessionCell.Accessory? = nil,
extraAction: SessionCell.ExtraAction? = nil,
isEnabled: Bool = true,
shouldHaveBackground: Bool = true,
accessibilityIdentifier: String? = nil,
accessibilityLabel: String? = nil,
leftAccessoryAccessibilityLabel: String? = nil,
rightAccessoryAccessibilityLabel: String? = nil,
confirmationInfo: ConfirmationModal.Info? = nil,
onTap: (() -> Void)? = nil
) {
self.id = id
self.leftAccessory = leftAccessory
self.title = title
self.subtitle = subtitle
self.subtitleExtraViewGenerator = subtitleExtraViewGenerator
self.tintColor = tintColor
self.rightAccessory = rightAccessory
self.extraAction = extraAction
self.isEnabled = isEnabled
self.shouldHaveBackground = shouldHaveBackground
self.accessibilityIdentifier = accessibilityIdentifier
self.accessibilityLabel = accessibilityLabel
self.leftAccessoryAccessibilityLabel = leftAccessoryAccessibilityLabel
self.rightAccessoryAccessibilityLabel = rightAccessoryAccessibilityLabel
self.confirmationInfo = confirmationInfo
self.onTap = (onTap != nil ? { _ in onTap?() } : nil)
self.onTapView = onTapView
}
// MARK: - Conformance
@ -110,37 +62,190 @@ extension SessionCell {
public func hash(into hasher: inout Hasher) {
id.hash(into: &hasher)
position.hash(into: &hasher)
leftAccessory.hash(into: &hasher)
title.hash(into: &hasher)
subtitle.hash(into: &hasher)
tintColor.hash(into: &hasher)
rightAccessory.hash(into: &hasher)
extraAction.hash(into: &hasher)
styling.hash(into: &hasher)
isEnabled.hash(into: &hasher)
shouldHaveBackground.hash(into: &hasher)
accessibilityIdentifier.hash(into: &hasher)
accessibilityLabel.hash(into: &hasher)
leftAccessoryAccessibilityLabel.hash(into: &hasher)
rightAccessoryAccessibilityLabel.hash(into: &hasher)
accessibility.hash(into: &hasher)
confirmationInfo.hash(into: &hasher)
}
public static func == (lhs: Info<ID>, rhs: Info<ID>) -> Bool {
return (
lhs.id == rhs.id &&
lhs.position == rhs.position &&
lhs.leftAccessory == rhs.leftAccessory &&
lhs.title == rhs.title &&
lhs.subtitle == rhs.subtitle &&
lhs.tintColor == rhs.tintColor &&
lhs.rightAccessory == rhs.rightAccessory &&
lhs.extraAction == rhs.extraAction &&
lhs.styling == rhs.styling &&
lhs.isEnabled == rhs.isEnabled &&
lhs.shouldHaveBackground == rhs.shouldHaveBackground &&
lhs.accessibilityIdentifier == rhs.accessibilityIdentifier &&
lhs.accessibilityLabel == rhs.accessibilityLabel &&
lhs.leftAccessoryAccessibilityLabel == rhs.leftAccessoryAccessibilityLabel &&
lhs.rightAccessoryAccessibilityLabel == rhs.rightAccessoryAccessibilityLabel
lhs.accessibility == rhs.accessibility
)
}
// MARK: - Convenience
public func updatedPosition(for index: Int, count: Int) -> Info {
return Info(
id: id,
position: Position.with(index, count: count),
leftAccessory: leftAccessory,
title: title,
subtitle: subtitle,
rightAccessory: rightAccessory,
styling: styling,
isEnabled: isEnabled,
accessibility: accessibility,
confirmationInfo: confirmationInfo,
onTap: onTap,
onTapView: onTapView
)
}
}
}
// MARK: - Convenience Initializers
public extension SessionCell.Info {
// Accessory, () -> Void
init(
id: ID,
position: Position = .individual,
accessory: SessionCell.Accessory,
styling: SessionCell.StyleInfo = SessionCell.StyleInfo(),
isEnabled: Bool = true,
accessibility: SessionCell.Accessibility? = nil,
confirmationInfo: ConfirmationModal.Info? = nil,
onTap: (() -> Void)? = nil
) {
self.id = id
self.position = position
self.leftAccessory = accessory
self.title = nil
self.subtitle = nil
self.rightAccessory = nil
self.styling = styling
self.isEnabled = isEnabled
self.accessibility = accessibility
self.confirmationInfo = confirmationInfo
self.onTap = onTap
self.onTapView = nil
}
// leftAccessory, rightAccessory
init(
id: ID,
position: Position = .individual,
leftAccessory: SessionCell.Accessory,
rightAccessory: SessionCell.Accessory,
styling: SessionCell.StyleInfo = SessionCell.StyleInfo(),
isEnabled: Bool = true,
accessibility: SessionCell.Accessibility? = nil,
confirmationInfo: ConfirmationModal.Info? = nil
) {
self.id = id
self.position = position
self.leftAccessory = leftAccessory
self.title = nil
self.subtitle = nil
self.rightAccessory = rightAccessory
self.styling = styling
self.isEnabled = isEnabled
self.accessibility = accessibility
self.confirmationInfo = confirmationInfo
self.onTap = nil
self.onTapView = nil
}
// String, () -> Void
init(
id: ID,
position: Position = .individual,
leftAccessory: SessionCell.Accessory? = nil,
title: String,
rightAccessory: SessionCell.Accessory? = nil,
styling: SessionCell.StyleInfo = SessionCell.StyleInfo(),
isEnabled: Bool = true,
accessibility: SessionCell.Accessibility? = nil,
confirmationInfo: ConfirmationModal.Info? = nil,
onTap: (() -> Void)? = nil
) {
self.id = id
self.position = position
self.leftAccessory = leftAccessory
self.title = SessionCell.TextInfo(title, font: .title)
self.subtitle = nil
self.rightAccessory = rightAccessory
self.styling = styling
self.isEnabled = isEnabled
self.accessibility = accessibility
self.confirmationInfo = confirmationInfo
self.onTap = onTap
self.onTapView = nil
}
// TextInfo, () -> Void
init(
id: ID,
position: Position = .individual,
leftAccessory: SessionCell.Accessory? = nil,
title: SessionCell.TextInfo,
rightAccessory: SessionCell.Accessory? = nil,
styling: SessionCell.StyleInfo = SessionCell.StyleInfo(),
isEnabled: Bool = true,
accessibility: SessionCell.Accessibility? = nil,
confirmationInfo: ConfirmationModal.Info? = nil,
onTap: (() -> Void)? = nil
) {
self.id = id
self.position = position
self.leftAccessory = leftAccessory
self.title = title
self.subtitle = nil
self.rightAccessory = rightAccessory
self.styling = styling
self.isEnabled = isEnabled
self.accessibility = accessibility
self.confirmationInfo = confirmationInfo
self.onTap = onTap
self.onTapView = nil
}
// String, String?, () -> Void
init(
id: ID,
position: Position = .individual,
leftAccessory: SessionCell.Accessory? = nil,
title: String,
subtitle: String?,
rightAccessory: SessionCell.Accessory? = nil,
styling: SessionCell.StyleInfo = SessionCell.StyleInfo(),
isEnabled: Bool = true,
accessibility: SessionCell.Accessibility? = nil,
confirmationInfo: ConfirmationModal.Info? = nil,
onTap: (() -> Void)? = nil,
onTapView: ((UIView?) -> Void)? = nil
) {
self.id = id
self.position = position
self.leftAccessory = leftAccessory
self.title = SessionCell.TextInfo(title, font: .title)
self.subtitle = SessionCell.TextInfo(subtitle, font: .subtitle)
self.rightAccessory = rightAccessory
self.styling = styling
self.isEnabled = isEnabled
self.accessibility = accessibility
self.confirmationInfo = confirmationInfo
self.onTap = onTap
self.onTapView = onTapView
}
}

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 DifferenceKit
import SessionUIKit
protocol SessionTableSection: Differentiable {
var title: String? { get }
@ -13,8 +14,36 @@ extension SessionTableSection {
var style: SessionTableSectionStyle { .none }
}
public enum SessionTableSectionStyle: Differentiable {
public enum SessionTableSectionStyle: Equatable, Hashable, Differentiable {
case none
case title
case titleRoundedContent
case titleEdgeToEdgeContent
case titleNoBackgroundContent
case titleSeparator
case padding
case loadMore
var height: CGFloat {
switch self {
case .none: return 0
case .titleRoundedContent, .titleEdgeToEdgeContent, .titleNoBackgroundContent:
return UITableView.automaticDimension
case .titleSeparator: return Separator.height
case .padding: return Values.smallSpacing
case .loadMore: return 40
}
}
/// These values should always be consistent with the padding in `SessionCell` to ensure the text lines up
var edgePadding: CGFloat {
switch self {
case .titleRoundedContent, .titleNoBackgroundContent:
// Align to the start of the text in the cell
return (Values.largeSpacing + Values.mediumSpacing)
case .titleEdgeToEdgeContent, .titleSeparator: return Values.largeSpacing
case .none, .padding, .loadMore: return 0
}
}
}

View File

@ -68,15 +68,15 @@ final class UserSelectionVC: BaseVC, UITableViewDataSource, UITableViewDelegate
cell.update(
with: SessionCell.Info(
id: profile,
leftAccessory: .profile(profile.id, profile),
position: Position.with(indexPath.row, count: users.count),
leftAccessory: .profile(id: profile.id, profile: profile),
title: profile.displayName(),
rightAccessory: .radio(isSelected: { [weak self] in
self?.selectedUsers.contains(profile.id) == true
}),
accessibilityIdentifier: "Contact"
),
style: .edgeToEdge,
position: Position.with(indexPath.row, count: users.count)
styling: SessionCell.StyleInfo(backgroundStyle: .edgeToEdge),
accessibility: SessionCell.Accessibility(identifier: "Contact")
)
)
return cell

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
extension SessionCell {
public class AccessoryView: UIView {
public class AccessoryView: UIView, UISearchBarDelegate {
// Note: We set a minimum width for the 'AccessoryView' so that the titles line up
// nicely when we have a mix of icons and switches
private static let minWidth: CGFloat = 50
private var onTap: ((SessionButton?) -> Void)?
private var searchTermChanged: ((String?) -> Void)?
// MARK: - UI
private lazy var minWidthConstraint: NSLayoutConstraint = self.widthAnchor
.constraint(greaterThanOrEqualToConstant: AccessoryView.minWidth)
private lazy var fixedWidthConstraint: NSLayoutConstraint = self.set(.width, to: AccessoryView.minWidth)
private lazy var imageViewConstraints: [NSLayoutConstraint] = [
imageView.pin(.top, to: .top, of: self),
imageView.pin(.leading, to: .leading, of: self),
imageView.pin(.trailing, to: .trailing, of: self),
imageView.pin(.bottom, to: .bottom, of: self)
]
private lazy var imageViewLeadingConstraint: NSLayoutConstraint = imageView.pin(.leading, to: .leading, of: self)
private lazy var imageViewTrailingConstraint: NSLayoutConstraint = imageView.pin(.trailing, to: .trailing, of: self)
private lazy var imageViewWidthConstraint: NSLayoutConstraint = imageView.set(.width, to: 0)
private lazy var imageViewHeightConstraint: NSLayoutConstraint = imageView.set(.height, to: 0)
private lazy var toggleSwitchConstraints: [NSLayoutConstraint] = [
@ -26,8 +36,8 @@ extension SessionCell {
]
private lazy var dropDownStackViewConstraints: [NSLayoutConstraint] = [
dropDownStackView.pin(.top, to: .top, of: self),
dropDownStackView.pin(.leading, to: .leading, of: self),
dropDownStackView.pin(.trailing, to: .trailing, of: self),
dropDownStackView.pin(.leading, to: .leading, of: self, withInset: Values.smallSpacing),
dropDownStackView.pin(.trailing, to: .trailing, of: self, withInset: -Values.smallSpacing),
dropDownStackView.pin(.bottom, to: .bottom, of: self)
]
private lazy var radioViewWidthConstraint: NSLayoutConstraint = radioView.set(.width, to: 0)
@ -36,22 +46,35 @@ extension SessionCell {
private lazy var radioBorderViewHeightConstraint: NSLayoutConstraint = radioBorderView.set(.height, to: 0)
private lazy var radioBorderViewConstraints: [NSLayoutConstraint] = [
radioBorderView.pin(.top, to: .top, of: self),
radioBorderView.pin(.leading, to: .leading, of: self),
radioBorderView.pin(.trailing, to: .trailing, of: self),
radioBorderView.center(.horizontal, in: self),
radioBorderView.pin(.bottom, to: .bottom, of: self)
]
private lazy var highlightingBackgroundLabelConstraints: [NSLayoutConstraint] = [
highlightingBackgroundLabel.pin(.top, to: .top, of: self),
highlightingBackgroundLabel.pin(.leading, to: .leading, of: self),
highlightingBackgroundLabel.pin(.trailing, to: .trailing, of: self),
highlightingBackgroundLabel.pin(.leading, to: .leading, of: self, withInset: Values.smallSpacing),
highlightingBackgroundLabel.pin(.trailing, to: .trailing, of: self, withInset: -Values.smallSpacing),
highlightingBackgroundLabel.pin(.bottom, to: .bottom, of: self)
]
private lazy var profilePictureViewLeadingConstraint: NSLayoutConstraint = profilePictureView.pin(.leading, to: .leading, of: self)
private lazy var profilePictureViewTrailingConstraint: NSLayoutConstraint = profilePictureView.pin(.trailing, to: .trailing, of: self)
private lazy var profilePictureViewConstraints: [NSLayoutConstraint] = [
profilePictureView.pin(.top, to: .top, of: self),
profilePictureView.pin(.leading, to: .leading, of: self),
profilePictureView.pin(.trailing, to: .trailing, of: self),
profilePictureView.pin(.bottom, to: .bottom, of: self)
]
private lazy var profilePictureViewWidthConstraint: NSLayoutConstraint = profilePictureView.set(.width, to: 0)
private lazy var profilePictureViewHeightConstraint: NSLayoutConstraint = profilePictureView.set(.height, to: 0)
private lazy var searchBarConstraints: [NSLayoutConstraint] = [
searchBar.pin(.top, to: .top, of: self),
searchBar.pin(.leading, to: .leading, of: self, withInset: -8), // Removing default inset
searchBar.pin(.trailing, to: .trailing, of: self, withInset: 8), // Removing default inset
searchBar.pin(.bottom, to: .bottom, of: self)
]
private lazy var buttonConstraints: [NSLayoutConstraint] = [
button.pin(.top, to: .top, of: self),
button.pin(.leading, to: .leading, of: self),
button.pin(.trailing, to: .trailing, of: self),
button.pin(.bottom, to: .bottom, of: self)
]
private let imageView: UIImageView = {
let result: UIImageView = UIImageView()
@ -143,10 +166,45 @@ extension SessionCell {
private lazy var profilePictureView: ProfilePictureView = {
let result: ProfilePictureView = ProfilePictureView()
result.translatesAutoresizingMaskIntoConstraints = false
result.size = Values.smallProfilePictureSize
result.isHidden = true
result.set(.width, to: Values.smallProfilePictureSize)
result.set(.height, to: Values.smallProfilePictureSize)
return result
}()
private lazy var profileIconContainerView: UIView = {
let result: UIView = UIView()
result.translatesAutoresizingMaskIntoConstraints = false
result.themeBackgroundColor = .primary
result.isHidden = true
result.set(.width, to: 26)
result.set(.height, to: 26)
result.layer.cornerRadius = (26 / 2)
return result
}()
private lazy var profileIconImageView: UIImageView = {
let result: UIImageView = UIImageView()
result.translatesAutoresizingMaskIntoConstraints = false
return result
}()
private lazy var searchBar: UISearchBar = {
let result: ContactsSearchBar = ContactsSearchBar()
result.themeTintColor = .textPrimary
result.themeBackgroundColor = .clear
result.searchTextField.themeBackgroundColor = .backgroundSecondary
result.delegate = self
return result
}()
private lazy var button: SessionButton = {
let result: SessionButton = SessionButton(style: .bordered, size: .medium)
result.translatesAutoresizingMaskIntoConstraints = false
result.addTarget(self, action: #selector(buttonTapped), for: .touchUpInside)
result.isHidden = true
return result
}()
@ -174,18 +232,29 @@ extension SessionCell {
addSubview(radioBorderView)
addSubview(highlightingBackgroundLabel)
addSubview(profilePictureView)
addSubview(profileIconContainerView)
addSubview(button)
addSubview(searchBar)
dropDownStackView.addArrangedSubview(dropDownImageView)
dropDownStackView.addArrangedSubview(dropDownLabel)
radioBorderView.addSubview(radioView)
radioView.center(in: radioBorderView)
profileIconContainerView.addSubview(profileIconImageView)
profileIconContainerView.pin(.bottom, to: .bottom, of: profilePictureView)
profileIconContainerView.pin(.trailing, to: .trailing, of: profilePictureView)
profileIconImageView.pin(to: profileIconContainerView, withInset: Values.verySmallSpacing)
}
// MARK: - Content
func prepareForReuse() {
self.isHidden = true
isHidden = true
onTap = nil
searchTermChanged = nil
imageView.image = nil
imageView.themeTintColor = .textPrimary
@ -207,7 +276,16 @@ extension SessionCell {
radioView.isHidden = true
highlightingBackgroundLabel.isHidden = true
profilePictureView.isHidden = true
profileIconContainerView.isHidden = true
button.isHidden = true
searchBar.isHidden = true
minWidthConstraint.constant = AccessoryView.minWidth
minWidthConstraint.isActive = false
fixedWidthConstraint.constant = AccessoryView.minWidth
fixedWidthConstraint.isActive = false
imageViewLeadingConstraint.isActive = false
imageViewTrailingConstraint.isActive = false
imageViewWidthConstraint.isActive = false
imageViewHeightConstraint.isActive = false
imageViewConstraints.forEach { $0.isActive = false }
@ -219,14 +297,19 @@ extension SessionCell {
radioBorderViewHeightConstraint.isActive = false
radioBorderViewConstraints.forEach { $0.isActive = false }
highlightingBackgroundLabelConstraints.forEach { $0.isActive = false }
profilePictureViewLeadingConstraint.isActive = false
profilePictureViewTrailingConstraint.isActive = false
profilePictureViewWidthConstraint.isActive = false
profilePictureViewHeightConstraint.isActive = false
profilePictureViewConstraints.forEach { $0.isActive = false }
searchBarConstraints.forEach { $0.isActive = false }
buttonConstraints.forEach { $0.isActive = false }
}
public func update(
with accessory: Accessory?,
tintColor: ThemeValue,
isEnabled: Bool,
accessibilityLabel: String?
isEnabled: Bool
) {
guard let accessory: Accessory = accessory else { return }
@ -234,8 +317,9 @@ extension SessionCell {
self.isHidden = false
switch accessory {
case .icon(let image, let iconSize, let customTint, let shouldFill):
imageView.accessibilityLabel = accessibilityLabel
case .icon(let image, let iconSize, let customTint, let shouldFill, let accessibility):
imageView.accessibilityIdentifier = accessibility?.identifier
imageView.accessibilityLabel = accessibility?.label
imageView.image = image
imageView.themeTintColor = (customTint ?? tintColor)
imageView.contentMode = (shouldFill ? .scaleAspectFill : .scaleAspectFit)
@ -244,21 +328,30 @@ extension SessionCell {
switch iconSize {
case .fit:
imageView.sizeToFit()
fixedWidthConstraint.constant = (imageView.bounds.width + (shouldFill ? 0 : (Values.smallSpacing * 2)))
fixedWidthConstraint.isActive = true
imageViewWidthConstraint.constant = imageView.bounds.width
imageViewHeightConstraint.constant = imageView.bounds.height
default:
fixedWidthConstraint.isActive = (iconSize.size <= fixedWidthConstraint.constant)
imageViewWidthConstraint.constant = iconSize.size
imageViewHeightConstraint.constant = iconSize.size
}
minWidthConstraint.isActive = !fixedWidthConstraint.isActive
imageViewLeadingConstraint.constant = (shouldFill ? 0 : Values.smallSpacing)
imageViewTrailingConstraint.constant = (shouldFill ? 0 : -Values.smallSpacing)
imageViewLeadingConstraint.isActive = true
imageViewTrailingConstraint.isActive = true
imageViewWidthConstraint.isActive = true
imageViewHeightConstraint.isActive = true
imageViewConstraints.forEach { $0.isActive = true }
case .iconAsync(let iconSize, let customTint, let shouldFill, let setter):
case .iconAsync(let iconSize, let customTint, let shouldFill, let accessibility, let setter):
setter(imageView)
imageView.accessibilityLabel = accessibilityLabel
imageView.accessibilityIdentifier = accessibility?.identifier
imageView.accessibilityLabel = accessibility?.label
imageView.themeTintColor = (customTint ?? tintColor)
imageView.contentMode = (shouldFill ? .scaleAspectFill : .scaleAspectFit)
imageView.isHidden = false
@ -266,22 +359,33 @@ extension SessionCell {
switch iconSize {
case .fit:
imageView.sizeToFit()
fixedWidthConstraint.constant = (imageView.bounds.width + (shouldFill ? 0 : (Values.smallSpacing * 2)))
fixedWidthConstraint.isActive = true
imageViewWidthConstraint.constant = imageView.bounds.width
imageViewHeightConstraint.constant = imageView.bounds.height
default:
fixedWidthConstraint.isActive = (iconSize.size <= fixedWidthConstraint.constant)
imageViewWidthConstraint.constant = iconSize.size
imageViewHeightConstraint.constant = iconSize.size
}
minWidthConstraint.isActive = !fixedWidthConstraint.isActive
imageViewLeadingConstraint.constant = (shouldFill ? 0 : Values.smallSpacing)
imageViewTrailingConstraint.constant = (shouldFill ? 0 : -Values.smallSpacing)
imageViewLeadingConstraint.isActive = true
imageViewTrailingConstraint.isActive = true
imageViewWidthConstraint.isActive = true
imageViewHeightConstraint.isActive = true
imageViewConstraints.forEach { $0.isActive = true }
case .toggle(let dataSource):
toggleSwitch.accessibilityLabel = accessibilityLabel
case .toggle(let dataSource, let accessibility):
toggleSwitch.accessibilityIdentifier = accessibility?.identifier
toggleSwitch.accessibilityLabel = accessibility?.label
toggleSwitch.isHidden = false
toggleSwitch.isEnabled = isEnabled
fixedWidthConstraint.isActive = true
toggleSwitchConstraints.forEach { $0.isActive = true }
let newValue: Bool = dataSource.currentBoolValue
@ -290,13 +394,15 @@ extension SessionCell {
toggleSwitch.setOn(newValue, animated: true)
}
case .dropDown(let dataSource):
dropDownLabel.accessibilityLabel = accessibilityLabel
case .dropDown(let dataSource, let accessibility):
dropDownLabel.accessibilityIdentifier = accessibility?.identifier
dropDownLabel.accessibilityLabel = accessibility?.label
dropDownLabel.text = dataSource.currentStringValue
dropDownStackView.isHidden = false
dropDownStackViewConstraints.forEach { $0.isActive = true }
minWidthConstraint.isActive = true
case .radio(let size, let isSelectedRetriever, let storedSelection):
case .radio(let size, let isSelectedRetriever, let storedSelection, let accessibility):
let isSelected: Bool = isSelectedRetriever()
let wasOldSelection: Bool = (!isSelected && storedSelection)
@ -307,7 +413,8 @@ extension SessionCell {
)
radioBorderView.layer.cornerRadius = (size.borderSize / 2)
radioView.accessibilityLabel = accessibilityLabel
radioView.accessibilityIdentifier = accessibility?.identifier
radioView.accessibilityLabel = accessibility?.label
radioView.alpha = (wasOldSelection ? 0.3 : 1)
radioView.isHidden = (!isSelected && !storedSelection)
radioView.themeBackgroundColor = (isSelected || wasOldSelection ?
@ -321,32 +428,89 @@ extension SessionCell {
radioBorderViewWidthConstraint.constant = size.borderSize
radioBorderViewHeightConstraint.constant = size.borderSize
fixedWidthConstraint.isActive = true
radioViewWidthConstraint.isActive = true
radioViewHeightConstraint.isActive = true
radioBorderViewWidthConstraint.isActive = true
radioBorderViewHeightConstraint.isActive = true
radioBorderViewConstraints.forEach { $0.isActive = true }
case .highlightingBackgroundLabel(let title):
highlightingBackgroundLabel.accessibilityLabel = accessibilityLabel
case .highlightingBackgroundLabel(let title, let accessibility):
highlightingBackgroundLabel.accessibilityIdentifier = accessibility?.identifier
highlightingBackgroundLabel.accessibilityLabel = accessibility?.label
highlightingBackgroundLabel.text = title
highlightingBackgroundLabel.themeTextColor = tintColor
highlightingBackgroundLabel.isHidden = false
highlightingBackgroundLabelConstraints.forEach { $0.isActive = true }
minWidthConstraint.isActive = true
case .profile(let profileId, let profile):
profilePictureView.accessibilityLabel = accessibilityLabel
case .profile(
let profileId,
let profileSize,
let threadVariant,
let customImageData,
let profile,
let additionalProfile,
let cornerIcon,
let accessibility
):
// Note: We MUST set the 'size' property before triggering the 'update'
// function or the profile picture won't layout correctly
switch profileSize {
case .fit:
profilePictureView.size = IconSize.large.size
profilePictureViewWidthConstraint.constant = IconSize.large.size
profilePictureViewHeightConstraint.constant = IconSize.large.size
default:
profilePictureView.size = profileSize.size
profilePictureViewWidthConstraint.constant = profileSize.size
profilePictureViewHeightConstraint.constant = profileSize.size
}
profilePictureView.accessibilityIdentifier = accessibility?.identifier
profilePictureView.accessibilityLabel = accessibility?.label
profilePictureView.update(
publicKey: profileId,
threadVariant: threadVariant,
customImageData: customImageData,
profile: profile,
threadVariant: .contact
additionalProfile: additionalProfile
)
profilePictureView.isHidden = false
profileIconContainerView.isHidden = (cornerIcon == nil)
profileIconImageView.image = cornerIcon
fixedWidthConstraint.constant = profilePictureViewWidthConstraint.constant
fixedWidthConstraint.isActive = true
profilePictureViewLeadingConstraint.constant = (profilePictureView.size > AccessoryView.minWidth ? 0 : Values.smallSpacing)
profilePictureViewTrailingConstraint.constant = (profilePictureView.size > AccessoryView.minWidth ? 0 : -Values.smallSpacing)
profilePictureViewLeadingConstraint.isActive = true
profilePictureViewTrailingConstraint.isActive = true
profilePictureViewWidthConstraint.isActive = true
profilePictureViewHeightConstraint.isActive = true
profilePictureViewConstraints.forEach { $0.isActive = true }
case .customView(let viewGenerator):
case .search(let placeholder, let accessibility, let searchTermChanged):
self.searchTermChanged = searchTermChanged
searchBar.accessibilityIdentifier = accessibility?.identifier
searchBar.accessibilityLabel = accessibility?.label
searchBar.placeholder = placeholder
searchBar.isHidden = false
searchBarConstraints.forEach { $0.isActive = true }
case .button(let style, let title, let accessibility, let onTap):
self.onTap = onTap
button.accessibilityIdentifier = accessibility?.identifier
button.accessibilityLabel = accessibility?.label
button.setTitle(title, for: .normal)
button.setStyle(style)
button.isHidden = false
minWidthConstraint.isActive = true
buttonConstraints.forEach { $0.isActive = true }
case .customView(_, let viewGenerator):
let generatedView: UIView = viewGenerator()
generatedView.accessibilityLabel = accessibilityLabel
addSubview(generatedView)
generatedView.pin(.top, to: .top, of: self)
@ -354,10 +518,9 @@ extension SessionCell {
generatedView.pin(.trailing, to: .trailing, of: self)
generatedView.pin(.bottom, to: .bottom, of: self)
self.customView?.removeFromSuperview() // Just in case
self.customView = generatedView
case .threadInfo: break
customView?.removeFromSuperview() // Just in case
customView = generatedView
minWidthConstraint.isActive = true
}
}
@ -370,6 +533,27 @@ extension SessionCell {
func setSelected(_ selected: Bool, animated: Bool) {
highlightingBackgroundLabel.setSelected(selected, animated: animated)
}
@objc private func buttonTapped() {
onTap?(button)
}
// MARK: - UISearchBarDelegate
public func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
searchTermChanged?(searchText)
}
public func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) {
searchBar.setShowsCancelButton(true, animated: true)
}
public func searchBarTextDidEndEditing(_ searchBar: UISearchBar) {
searchBar.setShowsCancelButton(false, animated: true)
}
public func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
searchBar.endEditing(true)
}
}
}

View File

@ -1,6 +1,7 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import Combine
import GRDB
import DifferenceKit
import SessionUIKit
@ -9,17 +10,13 @@ import SessionUtilitiesKit
public class SessionCell: UITableViewCell {
public static let cornerRadius: CGFloat = 17
public enum Style {
case rounded
case roundedEdgeToEdge
case edgeToEdge
}
/// This value is here to allow the theming update callback to be released when preparing for reuse
private var instanceView: UIView = UIView()
private var position: Position?
private var isEditingTitle = false
public private(set) var interactionMode: SessionCell.TextInfo.Interaction = .none
private var shouldHighlightTitle: Bool = true
private var originalInputValue: String?
private var titleExtraView: UIView?
private var subtitleExtraView: UIView?
private var onExtraActionTap: (() -> Void)?
var disposables: Set<AnyCancellable> = Set()
// MARK: - UI
@ -29,8 +26,18 @@ public class SessionCell: UITableViewCell {
private var topSeparatorRightConstraint: NSLayoutConstraint = NSLayoutConstraint()
private var botSeparatorLeftConstraint: NSLayoutConstraint = NSLayoutConstraint()
private var botSeparatorRightConstraint: NSLayoutConstraint = NSLayoutConstraint()
private lazy var contentStackViewTopConstraint: NSLayoutConstraint = contentStackView.pin(.top, to: .top, of: cellBackgroundView)
private lazy var contentStackViewLeadingConstraint: NSLayoutConstraint = contentStackView.pin(.leading, to: .leading, of: cellBackgroundView)
private lazy var contentStackViewTrailingConstraint: NSLayoutConstraint = contentStackView.pin(.trailing, to: .trailing, of: cellBackgroundView)
private lazy var contentStackViewBottomConstraint: NSLayoutConstraint = contentStackView.pin(.bottom, to: .bottom, of: cellBackgroundView)
private lazy var contentStackViewHorizontalCenterConstraint: NSLayoutConstraint = contentStackView.center(.horizontal, in: cellBackgroundView)
private lazy var leftAccessoryFillConstraint: NSLayoutConstraint = contentStackView.set(.height, to: .height, of: leftAccessoryView)
private lazy var titleTextFieldLeadingConstraint: NSLayoutConstraint = titleTextField.pin(.leading, to: .leading, of: cellBackgroundView)
private lazy var titleTextFieldTrailingConstraint: NSLayoutConstraint = titleTextField.pin(.trailing, to: .trailing, of: cellBackgroundView)
private lazy var titleMinHeightConstraint: NSLayoutConstraint = titleStackView.heightAnchor
.constraint(greaterThanOrEqualTo: titleTextField.heightAnchor)
private lazy var rightAccessoryFillConstraint: NSLayoutConstraint = contentStackView.set(.height, to: .height, of: rightAccessoryView)
private lazy var accessoryWidthMatchConstraint: NSLayoutConstraint = leftAccessoryView.set(.width, to: .width, of: rightAccessoryView)
private let cellBackgroundView: UIView = {
let result: UIView = UIView()
@ -65,7 +72,6 @@ public class SessionCell: UITableViewCell {
result.distribution = .fill
result.alignment = .center
result.spacing = Values.mediumSpacing
result.isLayoutMarginsRelativeArrangement = true
return result
}()
@ -89,10 +95,10 @@ public class SessionCell: UITableViewCell {
return result
}()
private let titleLabel: UILabel = {
let result: UILabel = UILabel()
private let titleLabel: SRCopyableLabel = {
let result: SRCopyableLabel = SRCopyableLabel()
result.translatesAutoresizingMaskIntoConstraints = false
result.font = .boldSystemFont(ofSize: 15)
result.isUserInteractionEnabled = false
result.themeTextColor = .textPrimary
result.numberOfLines = 0
result.setCompressionResistanceHorizontalLow()
@ -101,10 +107,21 @@ public class SessionCell: UITableViewCell {
return result
}()
private let subtitleLabel: UILabel = {
let result: UILabel = UILabel()
fileprivate let titleTextField: UITextField = {
let textField: TextField = TextField(placeholder: "", usesDefaultHeight: false)
textField.translatesAutoresizingMaskIntoConstraints = false
textField.textAlignment = .center
textField.alpha = 0
textField.isHidden = true
textField.set(.height, to: Values.largeButtonHeight)
return textField
}()
private let subtitleLabel: SRCopyableLabel = {
let result: SRCopyableLabel = SRCopyableLabel()
result.translatesAutoresizingMaskIntoConstraints = false
result.font = .systemFont(ofSize: 13)
result.isUserInteractionEnabled = false
result.themeTextColor = .textPrimary
result.numberOfLines = 0
result.isHidden = true
@ -114,33 +131,6 @@ public class SessionCell: UITableViewCell {
return result
}()
private lazy var extraActionTopSpacingView: UIView = UIView.spacer(withHeight: Values.smallSpacing)
private lazy var extraActionButton: UIButton = {
let result: UIButton = UIButton()
result.translatesAutoresizingMaskIntoConstraints = false
result.titleLabel?.font = .boldSystemFont(ofSize: Values.smallFontSize)
result.titleLabel?.numberOfLines = 0
result.contentHorizontalAlignment = .left
result.contentEdgeInsets = UIEdgeInsets(
top: 8,
left: 0,
bottom: 0,
right: 0
)
result.addTarget(self, action: #selector(extraActionTapped), for: .touchUpInside)
result.isHidden = true
ThemeManager.onThemeChange(observer: result) { [weak result] theme, _ in
switch theme.interfaceStyle {
case .light: result?.setThemeTitleColor(.textPrimary, for: .normal)
default: result?.setThemeTitleColor(.primary, for: .normal)
}
}
return result
}()
public let rightAccessoryView: AccessoryView = {
let result: AccessoryView = AccessoryView()
result.isHidden = true
@ -186,8 +176,8 @@ public class SessionCell: UITableViewCell {
titleStackView.addArrangedSubview(titleLabel)
titleStackView.addArrangedSubview(subtitleLabel)
titleStackView.addArrangedSubview(extraActionTopSpacingView)
titleStackView.addArrangedSubview(extraActionButton)
cellBackgroundView.addSubview(titleTextField)
setupLayout()
}
@ -204,7 +194,10 @@ public class SessionCell: UITableViewCell {
topSeparatorLeftConstraint = topSeparator.pin(.left, to: .left, of: cellBackgroundView)
topSeparatorRightConstraint = topSeparator.pin(.right, to: .right, of: cellBackgroundView)
contentStackView.pin(to: cellBackgroundView)
contentStackViewTopConstraint.isActive = true
contentStackViewBottomConstraint.isActive = true
titleTextField.center(.vertical, in: titleLabel)
botSeparatorLeftConstraint = botSeparator.pin(.left, to: .left, of: cellBackgroundView)
botSeparatorRightConstraint = botSeparator.pin(.right, to: .right, of: cellBackgroundView)
@ -217,55 +210,59 @@ public class SessionCell: UITableViewCell {
// Need to force the contentStackView to layout if needed as it might not have updated it's
// sizing yet
self.contentStackView.layoutIfNeeded()
repositionExtraView(titleExtraView, for: titleLabel)
repositionExtraView(subtitleExtraView, for: subtitleLabel)
}
private func repositionExtraView(_ targetView: UIView?, for label: UILabel) {
guard
let targetView: UIView = targetView,
let content: String = label.text,
let font: UIFont = label.font
else { return }
// Position the 'subtitleExtraView' at the end of the last line of text
if
let subtitleExtraView: UIView = self.subtitleExtraView,
let subtitle: String = subtitleLabel.text,
let font: UIFont = subtitleLabel.font
{
let layoutManager: NSLayoutManager = NSLayoutManager()
let textStorage = NSTextStorage(
attributedString: NSAttributedString(
string: subtitle,
attributes: [ .font: font ]
)
// Position the 'targetView' at the end of the last line of text
let layoutManager: NSLayoutManager = NSLayoutManager()
let textStorage = NSTextStorage(
attributedString: NSAttributedString(
string: content,
attributes: [ .font: font ]
)
textStorage.addLayoutManager(layoutManager)
let textContainer: NSTextContainer = NSTextContainer(
size: CGSize(
width: subtitleLabel.bounds.size.width,
height: 999
)
)
textStorage.addLayoutManager(layoutManager)
let textContainer: NSTextContainer = NSTextContainer(
size: CGSize(
width: label.bounds.size.width,
height: 999
)
textContainer.lineFragmentPadding = 0
layoutManager.addTextContainer(textContainer)
var glyphRange: NSRange = NSRange()
layoutManager.characterRange(
forGlyphRange: NSRange(location: subtitle.glyphCount - 1, length: 1),
actualGlyphRange: &glyphRange
)
let lastGlyphRect: CGRect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer)
// Remove and re-add the 'subtitleExtraView' to clear any old constraints
subtitleExtraView.removeFromSuperview()
contentView.addSubview(subtitleExtraView)
subtitleExtraView.pin(
.top,
to: .top,
of: subtitleLabel,
withInset: (lastGlyphRect.minY + ((lastGlyphRect.height / 2) - (subtitleExtraView.bounds.height / 2)))
)
subtitleExtraView.pin(
.leading,
to: .leading,
of: subtitleLabel,
withInset: lastGlyphRect.maxX
)
}
)
textContainer.lineFragmentPadding = 0
layoutManager.addTextContainer(textContainer)
var glyphRange: NSRange = NSRange()
layoutManager.characterRange(
forGlyphRange: NSRange(location: content.glyphCount - 1, length: 1),
actualGlyphRange: &glyphRange
)
let lastGlyphRect: CGRect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer)
// Remove and re-add the 'subtitleExtraView' to clear any old constraints
targetView.removeFromSuperview()
contentView.addSubview(targetView)
targetView.pin(
.top,
to: .top,
of: label,
withInset: (lastGlyphRect.minY + ((lastGlyphRect.height / 2) - (targetView.bounds.height / 2)))
)
targetView.pin(
.leading,
to: .leading,
of: label,
withInset: lastGlyphRect.maxX
)
}
// MARK: - Content
@ -273,108 +270,185 @@ public class SessionCell: UITableViewCell {
public override func prepareForReuse() {
super.prepareForReuse()
self.instanceView = UIView()
self.position = nil
self.onExtraActionTap = nil
self.accessibilityIdentifier = nil
isEditingTitle = false
interactionMode = .none
shouldHighlightTitle = true
accessibilityIdentifier = nil
accessibilityLabel = nil
originalInputValue = nil
titleExtraView?.removeFromSuperview()
titleExtraView = nil
subtitleExtraView?.removeFromSuperview()
subtitleExtraView = nil
disposables = Set()
contentStackView.spacing = Values.mediumSpacing
contentStackViewLeadingConstraint.isActive = false
contentStackViewTrailingConstraint.isActive = false
contentStackViewHorizontalCenterConstraint.isActive = false
titleMinHeightConstraint.isActive = false
leftAccessoryView.prepareForReuse()
leftAccessoryView.alpha = 1
leftAccessoryFillConstraint.isActive = false
titleLabel.text = ""
titleLabel.textAlignment = .left
titleLabel.themeTextColor = .textPrimary
titleLabel.alpha = 1
titleTextField.text = ""
titleTextField.textAlignment = .center
titleTextField.themeTextColor = .textPrimary
titleTextField.isHidden = true
titleTextField.alpha = 0
subtitleLabel.isUserInteractionEnabled = false
subtitleLabel.text = ""
subtitleLabel.themeTextColor = .textPrimary
rightAccessoryView.prepareForReuse()
rightAccessoryView.alpha = 1
rightAccessoryFillConstraint.isActive = false
accessoryWidthMatchConstraint.isActive = false
topSeparator.isHidden = true
subtitleLabel.isHidden = true
extraActionTopSpacingView.isHidden = true
extraActionButton.setTitle("", for: .normal)
extraActionButton.isHidden = true
botSeparator.isHidden = true
subtitleExtraView?.removeFromSuperview()
subtitleExtraView = nil
}
public func update<ID: Hashable & Differentiable>(
with info: Info<ID>,
style: Style,
position: Position
) {
self.instanceView = UIView()
self.position = position
self.subtitleExtraView = info.subtitleExtraViewGenerator?()
self.onExtraActionTap = info.extraAction?.onTap
self.accessibilityIdentifier = info.accessibilityIdentifier
self.accessibilityLabel = info.accessibilityLabel
self.isAccessibilityElement = true
public func update<ID: Hashable & Differentiable>(with info: Info<ID>) {
interactionMode = (info.title?.interaction ?? .none)
shouldHighlightTitle = (info.title?.interaction != .copy)
titleExtraView = info.title?.extraViewGenerator?()
subtitleExtraView = info.subtitle?.extraViewGenerator?()
accessibilityIdentifier = info.accessibility?.identifier
accessibilityLabel = info.accessibility?.label
originalInputValue = info.title?.text
// Convenience Flags
let leftFitToEdge: Bool = (info.leftAccessory?.shouldFitToEdge == true)
let rightFitToEdge: Bool = (!leftFitToEdge && info.rightAccessory?.shouldFitToEdge == true)
leftAccessoryFillConstraint.isActive = leftFitToEdge
// Content
contentStackView.spacing = (info.styling.customPadding?.interItem ?? Values.mediumSpacing)
leftAccessoryView.update(
with: info.leftAccessory,
tintColor: info.tintColor,
isEnabled: info.isEnabled,
accessibilityLabel: info.leftAccessoryAccessibilityLabel
tintColor: info.styling.tintColor,
isEnabled: info.isEnabled
)
titleStackView.isHidden = (info.title == nil && info.subtitle == nil)
titleLabel.isUserInteractionEnabled = (info.title?.interaction == .copy)
titleLabel.font = info.title?.font
titleLabel.text = info.title?.text
titleLabel.themeTextColor = info.styling.tintColor
titleLabel.textAlignment = (info.title?.textAlignment ?? .left)
titleLabel.isHidden = (info.title == nil)
titleTextField.text = info.title?.text
titleTextField.textAlignment = (info.title?.textAlignment ?? .left)
titleTextField.placeholder = info.title?.editingPlaceholder
titleTextField.isHidden = (info.title == nil)
subtitleLabel.isUserInteractionEnabled = (info.subtitle?.interaction == .copy)
subtitleLabel.font = info.subtitle?.font
subtitleLabel.text = info.subtitle?.text
subtitleLabel.themeTextColor = info.styling.tintColor
subtitleLabel.textAlignment = (info.subtitle?.textAlignment ?? .left)
subtitleLabel.isHidden = (info.subtitle == nil)
rightAccessoryView.update(
with: info.rightAccessory,
tintColor: info.tintColor,
isEnabled: info.isEnabled,
accessibilityLabel: info.rightAccessoryAccessibilityLabel
)
rightAccessoryFillConstraint.isActive = rightFitToEdge
contentStackView.layoutMargins = UIEdgeInsets(
top: (leftFitToEdge || rightFitToEdge ? 0 : Values.mediumSpacing),
left: (leftFitToEdge ? 0 : Values.largeSpacing),
bottom: (leftFitToEdge || rightFitToEdge ? 0 : Values.mediumSpacing),
right: (rightFitToEdge ? 0 : Values.largeSpacing)
tintColor: info.styling.tintColor,
isEnabled: info.isEnabled
)
titleLabel.text = info.title
titleLabel.themeTextColor = info.tintColor
subtitleLabel.text = info.subtitle
subtitleLabel.themeTextColor = info.tintColor
subtitleLabel.isHidden = (info.subtitle == nil)
extraActionTopSpacingView.isHidden = (info.extraAction == nil)
extraActionButton.setTitle(info.extraAction?.title, for: .normal)
extraActionButton.isHidden = (info.extraAction == nil)
contentStackViewLeadingConstraint.isActive = (info.styling.alignment == .leading)
contentStackViewTrailingConstraint.isActive = (info.styling.alignment == .leading)
contentStackViewHorizontalCenterConstraint.constant = ((info.styling.customPadding?.leading ?? 0) + (info.styling.customPadding?.trailing ?? 0))
contentStackViewHorizontalCenterConstraint.isActive = (info.styling.alignment == .centerHugging)
leftAccessoryFillConstraint.isActive = leftFitToEdge
rightAccessoryFillConstraint.isActive = rightFitToEdge
accessoryWidthMatchConstraint.isActive = {
switch (info.leftAccessory, info.rightAccessory) {
case (.button, .button): return true
default: return false
}
}()
titleLabel.setContentHuggingPriority(
(info.rightAccessory != nil ? .defaultLow : .required),
for: .horizontal
)
titleLabel.setContentCompressionResistancePriority(
(info.rightAccessory != nil ? .defaultLow : .required),
for: .horizontal
)
contentStackViewTopConstraint.constant = {
if let customPadding: CGFloat = info.styling.customPadding?.top {
return customPadding
}
return (leftFitToEdge || rightFitToEdge ? 0 : Values.mediumSpacing)
}()
contentStackViewLeadingConstraint.constant = {
if let customPadding: CGFloat = info.styling.customPadding?.leading {
return customPadding
}
return (leftFitToEdge ? 0 : Values.mediumSpacing)
}()
contentStackViewTrailingConstraint.constant = {
if let customPadding: CGFloat = info.styling.customPadding?.trailing {
return -customPadding
}
return -(rightFitToEdge ? 0 : Values.mediumSpacing)
}()
contentStackViewBottomConstraint.constant = {
if let customPadding: CGFloat = info.styling.customPadding?.bottom {
return -customPadding
}
return -(leftFitToEdge || rightFitToEdge ? 0 : Values.mediumSpacing)
}()
titleTextFieldLeadingConstraint.constant = {
guard info.styling.backgroundStyle != .noBackground else { return 0 }
return (leftFitToEdge ? 0 : Values.mediumSpacing)
}()
titleTextFieldTrailingConstraint.constant = {
guard info.styling.backgroundStyle != .noBackground else { return 0 }
return -(rightFitToEdge ? 0 : Values.mediumSpacing)
}()
// Styling and positioning
let defaultEdgePadding: CGFloat
cellBackgroundView.themeBackgroundColor = (info.shouldHaveBackground ?
.settings_tabBackground :
nil
)
cellSelectedBackgroundView.isHidden = (!info.isEnabled || !info.shouldHaveBackground)
switch style {
switch info.styling.backgroundStyle {
case .rounded:
cellBackgroundView.themeBackgroundColor = .settings_tabBackground
cellSelectedBackgroundView.isHidden = !info.isEnabled
defaultEdgePadding = Values.mediumSpacing
backgroundLeftConstraint.constant = Values.largeSpacing
backgroundRightConstraint.constant = -Values.largeSpacing
cellBackgroundView.layer.cornerRadius = SessionCell.cornerRadius
case .edgeToEdge:
cellBackgroundView.themeBackgroundColor = .settings_tabBackground
cellSelectedBackgroundView.isHidden = !info.isEnabled
defaultEdgePadding = 0
backgroundLeftConstraint.constant = 0
backgroundRightConstraint.constant = 0
cellBackgroundView.layer.cornerRadius = 0
case .roundedEdgeToEdge:
case .noBackground:
defaultEdgePadding = Values.mediumSpacing
backgroundLeftConstraint.constant = 0
backgroundRightConstraint.constant = 0
cellBackgroundView.layer.cornerRadius = SessionCell.cornerRadius
backgroundLeftConstraint.constant = Values.largeSpacing
backgroundRightConstraint.constant = -Values.largeSpacing
cellBackgroundView.themeBackgroundColor = nil
cellBackgroundView.layer.cornerRadius = 0
cellSelectedBackgroundView.isHidden = true
}
let fittedEdgePadding: CGFloat = {
func targetSize(accessory: Accessory?) -> CGFloat {
switch accessory {
case .icon(_, let iconSize, _, _), .iconAsync(let iconSize, _, _, _):
case .icon(_, let iconSize, _, _, _), .iconAsync(let iconSize, _, _, _, _):
return iconSize.size
default: return defaultEdgePadding
@ -394,43 +468,103 @@ public class SessionCell: UITableViewCell {
botSeparatorLeftConstraint.constant = (leftFitToEdge ? fittedEdgePadding : defaultEdgePadding)
botSeparatorRightConstraint.constant = (rightFitToEdge ? -fittedEdgePadding : -defaultEdgePadding)
switch position {
switch info.position {
case .top:
cellBackgroundView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
topSeparator.isHidden = (style != .edgeToEdge)
botSeparator.isHidden = false
topSeparator.isHidden = (
!info.styling.allowedSeparators.contains(.top) ||
info.styling.backgroundStyle != .edgeToEdge
)
botSeparator.isHidden = (
!info.styling.allowedSeparators.contains(.bottom) ||
info.styling.backgroundStyle == .noBackground
)
case .middle:
cellBackgroundView.layer.maskedCorners = []
topSeparator.isHidden = true
botSeparator.isHidden = false
botSeparator.isHidden = (
!info.styling.allowedSeparators.contains(.bottom) ||
info.styling.backgroundStyle == .noBackground
)
case .bottom:
cellBackgroundView.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner]
topSeparator.isHidden = false
botSeparator.isHidden = (style != .edgeToEdge)
topSeparator.isHidden = true
botSeparator.isHidden = (
!info.styling.allowedSeparators.contains(.bottom) ||
info.styling.backgroundStyle != .edgeToEdge
)
case .individual:
cellBackgroundView.layer.maskedCorners = [
.layerMinXMinYCorner, .layerMaxXMinYCorner,
.layerMinXMaxYCorner, .layerMaxXMaxYCorner
]
topSeparator.isHidden = (style != .edgeToEdge)
botSeparator.isHidden = (style != .edgeToEdge)
topSeparator.isHidden = (
!info.styling.allowedSeparators.contains(.top) ||
info.styling.backgroundStyle != .edgeToEdge
)
botSeparator.isHidden = (
!info.styling.allowedSeparators.contains(.bottom) ||
info.styling.backgroundStyle != .edgeToEdge
)
}
}
public func update(isEditing: Bool, animated: Bool) {}
public func update(isEditing: Bool, becomeFirstResponder: Bool, animated: Bool) {
// Note: We set 'isUserInteractionEnabled' based on the 'info.isEditable' flag
// so can use that to determine whether this element can become editable
guard interactionMode == .editable || interactionMode == .alwaysEditing else { return }
self.isEditingTitle = isEditing
let changes = { [weak self] in
self?.titleLabel.alpha = (isEditing ? 0 : 1)
self?.titleTextField.alpha = (isEditing ? 1 : 0)
self?.leftAccessoryView.alpha = (isEditing ? 0 : 1)
self?.rightAccessoryView.alpha = (isEditing ? 0 : 1)
self?.titleMinHeightConstraint.isActive = isEditing
}
let completion: (Bool) -> Void = { [weak self] complete in
self?.titleTextField.text = self?.originalInputValue
}
if animated {
UIView.animate(withDuration: 0.25, animations: changes, completion: completion)
}
else {
changes()
completion(true)
}
if isEditing && becomeFirstResponder {
titleTextField.becomeFirstResponder()
}
else if !isEditing {
titleTextField.resignFirstResponder()
}
}
// MARK: - Interaction
public override func setHighlighted(_ highlighted: Bool, animated: Bool) {
super.setHighlighted(highlighted, animated: animated)
// When editing disable the highlighted state changes (would result in UI elements
// reappearing otherwise)
guard !self.isEditingTitle else { return }
// If the 'cellSelectedBackgroundView' is hidden then there is no background so we
// should update the titleLabel to indicate the highlighted state
if cellSelectedBackgroundView.isHidden {
titleLabel.alpha = (highlighted ? 0.8 : 1)
if cellSelectedBackgroundView.isHidden && shouldHighlightTitle {
// Note: We delay the "unhighlight" of the titleLabel so that the transition doesn't
// conflict with the transition into edit mode
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { [weak self] in
guard self?.isEditingTitle == false else { return }
self?.titleLabel.alpha = (highlighted ? 0.8 : 1)
}
}
cellSelectedBackgroundView.alpha = (highlighted ? 1 : 0)
@ -440,12 +574,18 @@ public class SessionCell: UITableViewCell {
public override func setSelected(_ selected: Bool, animated: Bool) {
super.setSelected(selected, animated: animated)
leftAccessoryView.setSelected(selected, animated: animated)
rightAccessoryView.setSelected(selected, animated: animated)
}
@objc private func extraActionTapped() {
onExtraActionTap?()
}
// MARK: - Compose
extension CombineCompatible where Self: SessionCell {
var textPublisher: AnyPublisher<String, Never> {
return self.titleTextField.publisher(for: .editingChanged)
.map { textField -> String in (textField.text ?? "") }
.eraseToAnyPublisher()
}
}

View File

@ -4,34 +4,44 @@ import UIKit
import SessionUIKit
class SessionHeaderView: UITableViewHeaderFooterView {
private lazy var emptyHeightConstraint: NSLayoutConstraint = self.heightAnchor
.constraint(equalToConstant: (Values.verySmallSpacing * 2))
private lazy var filledHeightConstraint: NSLayoutConstraint = self.heightAnchor
.constraint(greaterThanOrEqualToConstant: Values.mediumSpacing)
// MARK: - UI
private let stackView: UIStackView = {
let result: UIStackView = UIStackView()
result.translatesAutoresizingMaskIntoConstraints = false
result.axis = .vertical
result.distribution = .fill
result.alignment = .fill
result.isLayoutMarginsRelativeArrangement = true
return result
}()
private lazy var titleLabelConstraints: [NSLayoutConstraint] = [
titleLabel.pin(.top, to: .top, of: self, withInset: Values.mediumSpacing),
titleLabel.pin(.bottom, to: .bottom, of: self, withInset: -Values.mediumSpacing)
]
private lazy var titleLabelLeadingConstraint: NSLayoutConstraint = titleLabel.pin(.leading, to: .leading, of: self)
private lazy var titleLabelTrailingConstraint: NSLayoutConstraint = titleLabel.pin(.trailing, to: .trailing, of: self)
private lazy var titleSeparatorLeadingConstraint: NSLayoutConstraint = titleSeparator.pin(.leading, to: .leading, of: self)
private lazy var titleSeparatorTrailingConstraint: NSLayoutConstraint = titleSeparator.pin(.trailing, to: .trailing, of: self)
private let titleLabel: UILabel = {
let result: UILabel = UILabel()
result.translatesAutoresizingMaskIntoConstraints = false
result.font = .systemFont(ofSize: Values.mediumFontSize)
result.themeTextColor = .textSecondary
result.isHidden = true
return result
}()
private let separator: UIView = UIView.separator()
private let titleSeparator: Separator = {
let result: Separator = Separator()
result.isHidden = true
return result
}()
private let loadingIndicator: UIActivityIndicatorView = {
let result: UIActivityIndicatorView = UIActivityIndicatorView(style: .medium)
result.themeTintColor = .textPrimary
result.alpha = 0.5
result.startAnimating()
result.hidesWhenStopped = true
result.isHidden = true
return result
}()
// MARK: - Initialization
@ -41,10 +51,9 @@ class SessionHeaderView: UITableViewHeaderFooterView {
self.backgroundView = UIView()
self.backgroundView?.themeBackgroundColor = .backgroundPrimary
addSubview(stackView)
addSubview(separator)
stackView.addArrangedSubview(titleLabel)
addSubview(titleLabel)
addSubview(titleSeparator)
addSubview(loadingIndicator)
setupLayout()
}
@ -54,42 +63,59 @@ class SessionHeaderView: UITableViewHeaderFooterView {
}
private func setupLayout() {
stackView.pin(to: self)
titleLabel.pin(.top, to: .top, of: self, withInset: Values.mediumSpacing)
titleLabel.pin(.bottom, to: .bottom, of: self, withInset: Values.mediumSpacing)
titleLabel.center(.vertical, in: self)
separator.pin(.left, to: .left, of: self)
separator.pin(.right, to: .right, of: self)
separator.pin(.bottom, to: .bottom, of: self)
titleSeparator.center(.vertical, in: self)
loadingIndicator.center(in: self)
}
// MARK: - Content
override func prepareForReuse() {
super.prepareForReuse()
titleLabel.isHidden = true
titleSeparator.isHidden = true
loadingIndicator.isHidden = true
titleLabelLeadingConstraint.isActive = false
titleLabelTrailingConstraint.isActive = false
titleLabelConstraints.forEach { $0.isActive = false }
titleSeparator.center(.vertical, in: self)
titleSeparatorLeadingConstraint.isActive = false
titleSeparatorTrailingConstraint.isActive = false
}
public func update(
style: SessionCell.Style = .rounded,
title: String?,
hasSeparator: Bool
style: SessionTableSectionStyle = .titleRoundedContent
) {
let titleIsEmpty: Bool = (title ?? "").isEmpty
let edgePadding: CGFloat = {
switch style {
case .rounded:
// Align to the start of the text in the cell
return (Values.largeSpacing + Values.mediumSpacing)
case .edgeToEdge, .roundedEdgeToEdge: return Values.largeSpacing
}
}()
titleLabel.text = title
titleLabel.isHidden = titleIsEmpty
stackView.layoutMargins = UIEdgeInsets(
top: (titleIsEmpty ? Values.verySmallSpacing : Values.mediumSpacing),
left: edgePadding,
bottom: (titleIsEmpty ? Values.verySmallSpacing : Values.mediumSpacing),
right: edgePadding
)
emptyHeightConstraint.isActive = titleIsEmpty
filledHeightConstraint.isActive = !titleIsEmpty
separator.isHidden = (style == .rounded || !hasSeparator)
switch style {
case .titleRoundedContent, .titleEdgeToEdgeContent, .titleNoBackgroundContent:
titleLabel.text = title
titleLabel.isHidden = titleIsEmpty
titleLabelLeadingConstraint.constant = style.edgePadding
titleLabelTrailingConstraint.constant = -style.edgePadding
titleLabelLeadingConstraint.isActive = !titleIsEmpty
titleLabelTrailingConstraint.isActive = !titleIsEmpty
titleLabelConstraints.forEach { $0.isActive = true }
case .titleSeparator:
titleSeparator.update(title: title)
titleSeparator.isHidden = false
titleSeparatorLeadingConstraint.constant = style.edgePadding
titleSeparatorTrailingConstraint.constant = -style.edgePadding
titleSeparatorLeadingConstraint.isActive = !titleIsEmpty
titleSeparatorTrailingConstraint.isActive = !titleIsEmpty
case .none, .padding: break
case .loadMore: loadingIndicator.isHidden = false
}
self.layoutIfNeeded()
}

View File

@ -110,22 +110,14 @@ public final class BackgroundPoller {
.eraseToAnyPublisher()
}
let promises: [Promise<Void>] = jobsToRun.map { job -> Promise<Void> in
let (promise, seal) = Promise<Void>.pending()
// Note: In the background we just want jobs to fail silently
MessageReceiveJob.run(
job,
queue: DispatchQueue.main,
success: { _, _ in seal.fulfill(()) },
failure: { _, _, _ in seal.fulfill(()) },
deferred: { _ in seal.fulfill(()) }
)
return promise
}
return when(fulfilled: promises)
return ClosedGroupPoller.poll(
namespaces: ClosedGroupPoller.namespaces,
from: snode,
for: groupPublicKey,
on: DispatchQueue.main,
calledFromBackgroundPoller: true,
isBackgroundPollValid: { BackgroundPoller.isValid }
)
}
.eraseToAnyPublisher()
}

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
public extension ClosedGroup {
func asProfile() -> Profile {
return Profile(
id: threadId,
name: name,
profilePictureUrl: groupImageUrl,
profilePictureFileName: groupImageFileName,
profileEncryptionKey: groupImageEncryptionKey
)
}
static func removeKeysAndUnsubscribe(
_ db: Database? = nil,
threadId: String,

View File

@ -1,6 +1,7 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import SessionUtilitiesKit
extension FileServerAPI {
public enum Endpoint: EndpointType {
@ -8,7 +9,7 @@ extension FileServerAPI {
case fileIndividual(fileId: String)
case sessionVersion
var path: String {
public var path: String {
switch self {
case .file: return "file"
case .fileIndividual(let fileId): return "file/\(fileId)"

View File

@ -338,8 +338,6 @@ public final class ClosedGroupControlMessage: ControlMessage {
let contentProto = SNProtoContent.builder()
let dataMessageProto = SNProtoDataMessage.builder()
dataMessageProto.setClosedGroupControlMessage(try closedGroupControlMessage.build())
// Group context
try setGroupContextIfNeeded(db, on: dataMessageProto)
contentProto.setDataMessage(try dataMessageProto.build())
return try contentProto.build()
} catch {

View File

@ -77,13 +77,6 @@ public final class ExpirationTimerUpdate: ControlMessage {
dataMessageProto.setFlags(UInt32(SNProtoDataMessage.SNProtoDataMessageFlags.expirationTimerUpdate.rawValue))
dataMessageProto.setExpireTimer(duration)
if let syncTarget = syncTarget { dataMessageProto.setSyncTarget(syncTarget) }
// Group context
do {
try setGroupContextIfNeeded(db, on: dataMessageProto)
} catch {
SNLog("Couldn't construct expiration timer update proto from: \(self).")
return nil
}
let contentProto = SNProtoContent.builder()
do {
contentProto.setDataMessage(try dataMessageProto.build())

View File

@ -24,6 +24,13 @@ public extension Message {
)
case openGroupInbox(server: String, openGroupPublicKey: String, blindedPublicKey: String)
var namespace: SnodeAPI.Namespace {
switch self {
case .contact(_, let namespace), .closedGroup(_, let namespace): return namespace
default: preconditionFailure("Attepted to retrieve namespace for invalid destination")
}
}
public static func from(
_ db: Database,
thread: SessionThread,

View File

@ -63,18 +63,6 @@ public class Message: Codable {
public func toProto(_ db: Database) -> SNProtoContent? {
preconditionFailure("toProto(_:) is abstract and must be overridden.")
}
public func setGroupContextIfNeeded(_ db: Database, on dataMessage: SNProtoDataMessage.SNProtoDataMessageBuilder) throws {
guard
let threadId: String = threadId,
(try? ClosedGroup.exists(db, id: threadId)) == true,
let legacyGroupId: Data = "\(SMKLegacy.closedGroupIdPrefix)\(threadId)".data(using: .utf8)
else { return }
// Android needs a group context or it'll interpret the message as a one-to-one message
let groupProto = SNProtoGroupContext.builder(id: legacyGroupId, type: .deliver)
dataMessage.setGroup(try groupProto.build())
}
}
// MARK: - Message Parsing/Processing

View File

@ -158,7 +158,7 @@ public final class VisibleMessage: Message {
// Attachments
let attachments: [Attachment]? = try? Attachment.fetchAll(db, ids: self.attachmentIds)
let attachments: [Attachment]? = try? Attachment.fetchAll(db, ids: self.attachmentIds)
let attachmentProtos = (attachments ?? []).compactMap { $0.buildProto() }
dataMessage.setAttachments(attachmentProtos)
@ -175,14 +175,6 @@ public final class VisibleMessage: Message {
dataMessage.setReaction(reactionProto)
}
// Group context
do {
try setGroupContextIfNeeded(db, on: dataMessage)
} catch {
SNLog("Couldn't construct visible message proto from: \(self).")
return nil
}
// Sync target
if let syncTarget = syncTarget {
dataMessage.setSyncTarget(syncTarget)

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 let base64EncodedData: String = maybeBase64EncodedData, let base64EncodedSignature: String = maybeBase64EncodedSignature {
guard let sender: String = maybeSender, let data = Data(base64Encoded: base64EncodedData), let signature = Data(base64Encoded: base64EncodedSignature) else {
throw HTTP.Error.parsingFailed
throw HTTPError.parsingFailed
}
guard let dependencies: SMKDependencies = decoder.userInfo[Dependencies.userInfoKey] as? SMKDependencies else {
throw HTTP.Error.parsingFailed
throw HTTPError.parsingFailed
}
// Verify the signature based on the SessionId.Prefix type
@ -80,18 +80,18 @@ extension OpenGroupAPI.Message {
case .blinded:
guard dependencies.sign.verify(message: data.bytes, publicKey: publicKey.bytes, signature: signature.bytes) else {
SNLog("Ignoring message with invalid signature.")
throw HTTP.Error.parsingFailed
throw HTTPError.parsingFailed
}
case .standard, .unblinded:
guard (try? dependencies.ed25519.verifySignature(signature, publicKey: publicKey, data: data)) == true else {
SNLog("Ignoring message with invalid signature.")
throw HTTP.Error.parsingFailed
throw HTTPError.parsingFailed
}
case .none:
SNLog("Ignoring message with invalid sender.")
throw HTTP.Error.parsingFailed
throw HTTPError.parsingFailed
}
}

View File

@ -54,8 +54,8 @@ public enum OpenGroupAPI {
.defaulting(to: [])
// Generate the requests
let requestResponseType: [BatchRequestInfoType] = [
BatchRequestInfo(
let requestResponseType: [BatchRequest.Info] = [
BatchRequest.Info(
request: Request<NoBody, Endpoint>(
server: server,
endpoint: .capabilities
@ -71,7 +71,7 @@ public enum OpenGroupAPI {
.filter(OpenGroup.Columns.roomToken != "")
.fetchAll(db))
.defaulting(to: [])
.flatMap { openGroup -> [BatchRequestInfoType] in
.flatMap { openGroup -> [BatchRequest.Info] in
let shouldRetrieveRecentMessages: Bool = (
openGroup.sequenceNumber == 0 || (
// If it's the first poll for this launch and it's been longer than
@ -83,14 +83,14 @@ public enum OpenGroupAPI {
)
return [
BatchRequestInfo(
BatchRequest.Info(
request: Request<NoBody, Endpoint>(
server: server,
endpoint: .roomPollInfo(openGroup.roomToken, openGroup.infoUpdates)
),
responseType: RoomPollInfo.self
),
BatchRequestInfo(
BatchRequest.Info(
request: Request<NoBody, Endpoint>(
server: server,
endpoint: (shouldRetrieveRecentMessages ?
@ -113,7 +113,7 @@ public enum OpenGroupAPI {
!capabilities.contains(.blind) ? [] :
[
// Inbox
BatchRequestInfo(
BatchRequest.Info(
request: Request<NoBody, Endpoint>(
server: server,
endpoint: (lastInboxMessageId == 0 ?
@ -125,7 +125,7 @@ public enum OpenGroupAPI {
),
// Outbox
BatchRequestInfo(
BatchRequest.Info(
request: Request<NoBody, Endpoint>(
server: server,
endpoint: (lastOutboxMessageId == 0 ?
@ -151,7 +151,7 @@ public enum OpenGroupAPI {
private static func batch(
_ db: Database,
server: String,
requests: [BatchRequestInfoType],
requests: [BatchRequest.Info],
using dependencies: SMKDependencies = SMKDependencies()
) -> AnyPublisher<[Endpoint: (ResponseInfoType, Codable?)], Error> {
let responseTypes = requests.map { $0.responseType }
@ -163,7 +163,7 @@ public enum OpenGroupAPI {
method: .post,
server: server,
endpoint: Endpoint.batch,
body: requestBody
body: BatchRequest(requests: requests)
),
using: dependencies
)
@ -183,7 +183,7 @@ public enum OpenGroupAPI {
private static func sequence(
_ db: Database,
server: String,
requests: [BatchRequestInfoType],
requests: [BatchRequest.Info],
using dependencies: SMKDependencies = SMKDependencies()
) -> AnyPublisher<[Endpoint: (ResponseInfoType, Codable?)], Error> {
let responseTypes = requests.map { $0.responseType }
@ -195,7 +195,7 @@ public enum OpenGroupAPI {
method: .post,
server: server,
endpoint: Endpoint.sequence,
body: requestBody
body: BatchRequest(requests: requests)
),
using: dependencies
)
@ -315,7 +315,7 @@ public enum OpenGroupAPI {
) -> AnyPublisher<(capabilities: (info: ResponseInfoType, data: Capabilities), room: (info: ResponseInfoType, data: Room)), Error> {
let requestResponseType: [BatchRequest.Info] = [
// Get the latest capabilities for the server (in case it's a new server or the cached ones are stale)
BatchRequestInfo(
BatchRequest.Info(
request: Request<NoBody, Endpoint>(
server: server,
endpoint: .capabilities
@ -324,7 +324,7 @@ public enum OpenGroupAPI {
),
// And the room info
BatchRequestInfo(
BatchRequest.Info(
request: Request<NoBody, Endpoint>(
server: server,
endpoint: .room(roomToken)
@ -351,13 +351,13 @@ public enum OpenGroupAPI {
}
})
.map { _, value in value }
let maybeRoom: (info: OnionRequestResponseInfoType, data: Room?)? = maybeRoomResponse
.map { info, data in (info, (data as? BatchSubResponse<Room>)?.body) }
let maybeRoom: (info: ResponseInfoType, data: Room?)? = maybeRoomResponse
.map { info, data in (info, (data as? HTTP.BatchSubResponse<Room>)?.body) }
guard
let capabilitiesInfo: OnionRequestResponseInfoType = maybeCapabilities?.info,
let capabilitiesInfo: ResponseInfoType = maybeCapabilities?.info,
let capabilities: Capabilities = maybeCapabilities?.data,
let roomInfo: OnionRequestResponseInfoType = maybeRoom?.info,
let roomInfo: ResponseInfoType = maybeRoom?.info,
let room: Room = maybeRoom?.data
else {
return Fail(error: HTTPError.parsingFailed)
@ -383,7 +383,7 @@ public enum OpenGroupAPI {
) -> AnyPublisher<(capabilities: (info: ResponseInfoType, data: Capabilities), rooms: (info: ResponseInfoType, data: [Room])), Error> {
let requestResponseType: [BatchRequest.Info] = [
// Get the latest capabilities for the server (in case it's a new server or the cached ones are stale)
BatchRequestInfo(
BatchRequest.Info(
request: Request<NoBody, Endpoint>(
server: server,
endpoint: .capabilities
@ -392,7 +392,7 @@ public enum OpenGroupAPI {
),
// And the room info
BatchRequestInfo(
BatchRequest.Info(
request: Request<NoBody, Endpoint>(
server: server,
endpoint: .rooms
@ -419,13 +419,13 @@ public enum OpenGroupAPI {
}
})
.map { _, value in value }
let maybeRooms: (info: OnionRequestResponseInfoType, data: [Room]?)? = maybeRoomResponse
.map { info, data in (info, (data as? BatchSubResponse<[Room]>)?.body) }
let maybeRooms: (info: ResponseInfoType, data: [Room]?)? = maybeRoomResponse
.map { info, data in (info, (data as? HTTP.BatchSubResponse<[Room]>)?.body) }
guard
let capabilitiesInfo: OnionRequestResponseInfoType = maybeCapabilities?.info,
let capabilitiesInfo: ResponseInfoType = maybeCapabilities?.info,
let capabilities: Capabilities = maybeCapabilities?.data,
let roomsInfo: OnionRequestResponseInfoType = maybeRooms?.info,
let roomsInfo: ResponseInfoType = maybeRooms?.info,
let rooms: [Room] = maybeRooms?.data
else {
return Fail(error: HTTPError.parsingFailed)
@ -1239,16 +1239,16 @@ public enum OpenGroupAPI {
)
// Generate the requests
let requestResponseType: [BatchRequestInfoType] = [
BatchRequestInfo(
request: Request(
let requestResponseType: [BatchRequest.Info] = [
BatchRequest.Info(
request: Request<UserBanRequest, Endpoint>(
method: .post,
server: server,
endpoint: .userBan(sessionId),
body: banRequestBody
)
),
BatchRequestInfo(
BatchRequest.Info(
request: Request<NoBody, Endpoint>(
method: .delete,
server: server,
@ -1390,10 +1390,10 @@ public enum OpenGroupAPI {
updatedRequest.allHTTPHeaderFields = (request.allHTTPHeaderFields ?? [:])
.updated(with: [
Header.sogsPubKey.rawValue: signResult.publicKey,
Header.sogsTimestamp.rawValue: "\(timestamp)",
Header.sogsNonce.rawValue: nonce.base64EncodedString(),
Header.sogsSignature.rawValue: signResult.signature.toBase64()
HTTPHeader.sogsPubKey: signResult.publicKey,
HTTPHeader.sogsTimestamp: "\(timestamp)",
HTTPHeader.sogsNonce: nonce.base64EncodedString(),
HTTPHeader.sogsSignature: signResult.signature.toBase64()
])
return updatedRequest

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.
import Foundation
import SessionUtilitiesKit
extension OpenGroupAPI {
public enum Endpoint: EndpointType {
@ -58,7 +59,7 @@ extension OpenGroupAPI {
case userUnban(String)
case userModerator(String)
var path: String {
public var path: String {
switch self {
// Utility

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)
// Start polling
ClosedGroupPoller.shared.startPolling(for: groupPublicKey)
ClosedGroupPoller.shared.startIfNeeded(for: groupPublicKey)
// Notify the PN server
let _ = PushNotificationAPI.performOperation(.subscribe, for: groupPublicKey, publicKey: getUserHexEncodedPublicKey(db))

View File

@ -83,7 +83,7 @@ extension MessageSender {
.map { memberId -> MessageSender.PreparedSendData in
try MessageSender.preparedSendData(
db,
message: LegacyClosedGroupControlMessage(
message: ClosedGroupControlMessage(
kind: .new(
publicKey: Data(hex: groupPublicKey),
name: name,
@ -99,7 +99,7 @@ extension MessageSender {
// the 'ClosedGroup' object we created
sentTimestampMs: UInt64(floor(formationTimestamp * 1000))
),
to: .contact(publicKey: memberId),
to: .contact(publicKey: memberId, namespace: .default),
interactionId: nil
)
}
@ -263,7 +263,7 @@ extension MessageSender {
threadId: thread.id,
authorId: userPublicKey,
variant: .infoClosedGroupUpdated,
body: LegacyClosedGroupControlMessage.Kind
body: ClosedGroupControlMessage.Kind
.nameChange(name: name)
.infoMessage(db, sender: userPublicKey),
timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000))
@ -274,7 +274,7 @@ extension MessageSender {
// Send the update to the group
try MessageSender.send(
db,
message: LegacyClosedGroupControlMessage(kind: .nameChange(name: name)),
message: ClosedGroupControlMessage(kind: .nameChange(name: name)),
interactionId: interactionId,
in: thread
)
@ -493,7 +493,7 @@ extension MessageSender {
preparedSendData: try MessageSender
.preparedSendData(
db,
message: LegacyClosedGroupControlMessage(
message: ClosedGroupControlMessage(
kind: .membersRemoved(
members: removedMembers.map { Data(hex: $0) }
)
@ -546,7 +546,7 @@ extension MessageSender {
threadId: thread.id,
authorId: userPublicKey,
variant: .infoClosedGroupCurrentUserLeft,
body: LegacyClosedGroupControlMessage.Kind
body: ClosedGroupControlMessage.Kind
.memberLeft
.infoMessage(db, sender: userPublicKey),
timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000))
@ -561,7 +561,7 @@ extension MessageSender {
sendData = try MessageSender
.preparedSendData(
db,
message: LegacyClosedGroupControlMessage(
message: ClosedGroupControlMessage(
kind: .memberLeft
),
to: try Message.Destination.from(db, thread: thread),

View File

@ -85,8 +85,8 @@ extension MessageSender {
let threadId: String = {
switch destination {
case .contact(let publicKey): return publicKey
case .closedGroup(let groupPublicKey): return groupPublicKey
case .contact(let publicKey, _): return publicKey
case .closedGroup(let groupPublicKey, _): return groupPublicKey
case .openGroup(let roomToken, let server, _, _, _):
return OpenGroup.idFor(roomToken: roomToken, server: server)
@ -162,7 +162,10 @@ extension MessageSender {
// If we don't have a userKeyPair yet then there is no need to sync the configuration
// as the user doesn't exist yet (this will get triggered on the first launch of a
// fresh install due to the migrations getting run)
guard Identity.userExists(db) else {
guard
Identity.userExists(db),
let ed25519SecretKey: [UInt8] = Identity.fetchUserEd25519KeyPair(db)?.secretKey
else {
return Fail(error: StorageError.generic)
.eraseToAnyPublisher()
}
@ -205,41 +208,31 @@ extension MessageSender {
to: legacyDestination,
interactionId: nil
)
when(
resolved: try userConfigMessageChanges.map { message in
try MessageSender
.sendImmediate(
db,
message: message,
to: destination,
interactionId: nil
)
let userConfigSendData: [PreparedSendData] = try userConfigMessageChanges
.map { message in
try MessageSender.preparedSendData(
db,
message: message,
to: destination,
interactionId: nil
)
}
)
.done { results in
let hadError: Bool = results.contains { result in
switch result {
case .fulfilled: return false
case .rejected: return true
}
}
guard !hadError else {
seal.reject(StorageError.generic)
return
}
seal.fulfill(())
}
.catch { _ in seal.reject(StorageError.generic) }
.retainUntilComplete()
/// We want to avoid blocking the db write thread so we dispatch the API call to a different thread
return Just(())
.setFailureType(to: Error.self)
.receive(on: DispatchQueue.global(qos: .userInitiated))
.flatMap { _ in MessageSender.sendImmediate(preparedSendData: sendData) }
.flatMap { _ -> AnyPublisher<Void, Error> in
Publishers
.MergeMany(
([sendData] + userConfigSendData)
.map { MessageSender.sendImmediate(preparedSendData: $0) }
)
.collect()
.map { _ in () }
.eraseToAnyPublisher()
}
.eraseToAnyPublisher()
}
}

View File

@ -23,10 +23,6 @@ public final class MessageSender {
let plaintext: Data?
let ciphertext: Data?
// TODO: Replace these with the target namespaces
let isClosedGroupMessage: Bool
let isConfigMessage: Bool
private init(
shouldSend: Bool,
message: Message?,
@ -36,9 +32,7 @@ public final class MessageSender {
totalAttachmentsUploaded: Int = 0,
snodeMessage: SnodeMessage?,
plaintext: Data?,
ciphertext: Data?,
isClosedGroupMessage: Bool,
isConfigMessage: Bool
ciphertext: Data?
) {
self.shouldSend = shouldSend
@ -51,8 +45,6 @@ public final class MessageSender {
self.snodeMessage = snodeMessage
self.plaintext = plaintext
self.ciphertext = ciphertext
self.isClosedGroupMessage = isClosedGroupMessage
self.isConfigMessage = isConfigMessage
}
// The default constructor creats an instance that doesn't actually send a message
@ -68,8 +60,6 @@ public final class MessageSender {
self.snodeMessage = nil
self.plaintext = nil
self.ciphertext = nil
self.isClosedGroupMessage = false
self.isConfigMessage = false
}
/// This should be used to send a message to one-to-one or closed group conversations
@ -78,9 +68,7 @@ public final class MessageSender {
destination: Message.Destination,
interactionId: Int64?,
isSyncMessage: Bool?,
snodeMessage: SnodeMessage,
isClosedGroupMessage: Bool,
isConfigMessage: Bool
snodeMessage: SnodeMessage
) {
self.shouldSend = true
@ -93,8 +81,6 @@ public final class MessageSender {
self.snodeMessage = snodeMessage
self.plaintext = nil
self.ciphertext = nil
self.isClosedGroupMessage = isClosedGroupMessage
self.isConfigMessage = isConfigMessage
}
/// This should be used to send a message to open group conversations
@ -115,8 +101,6 @@ public final class MessageSender {
self.snodeMessage = nil
self.plaintext = plaintext
self.ciphertext = nil
self.isClosedGroupMessage = false
self.isConfigMessage = false
}
/// This should be used to send a message to an open group inbox
@ -137,8 +121,6 @@ public final class MessageSender {
self.snodeMessage = nil
self.plaintext = nil
self.ciphertext = ciphertext
self.isClosedGroupMessage = false
self.isConfigMessage = false
}
// MARK: - Mutation
@ -153,9 +135,7 @@ public final class MessageSender {
totalAttachmentsUploaded: fileIds.count,
snodeMessage: snodeMessage,
plaintext: plaintext,
ciphertext: ciphertext,
isClosedGroupMessage: isClosedGroupMessage,
isConfigMessage: isConfigMessage
ciphertext: ciphertext
)
}
}
@ -333,18 +313,15 @@ public final class MessageSender {
// Wrap the result
let kind: SNProtoEnvelope.SNProtoEnvelopeType
let senderPublicKey: String
let namespace: SnodeAPI.Namespace
switch destination {
case .contact(_, let targetNamespace):
case .contact:
kind = .sessionMessage
senderPublicKey = ""
namespace = targetNamespace
case .closedGroup(let groupPublicKey, let targetNamespace):
case .closedGroup(let groupPublicKey, _):
kind = .closedGroupMessage
senderPublicKey = groupPublicKey
namespace = targetNamespace
case .openGroup, .openGroupInbox: preconditionFailure()
}
@ -384,9 +361,7 @@ public final class MessageSender {
destination: destination,
interactionId: interactionId,
isSyncMessage: isSyncMessage,
snodeMessage: snodeMessage,
isClosedGroupMessage: (kind == .closedGroupMessage),
isConfigMessage: (message is ConfigurationMessage)
snodeMessage: snodeMessage
)
}
@ -667,10 +642,7 @@ public final class MessageSender {
return SnodeAPI
.sendMessage(
snodeMessage,
in: (data.isClosedGroupMessage ?
.legacyClosedGroup :
.default
)
in: destination.namespace
)
.subscribe(on: DispatchQueue.global(qos: .default))
.flatMap { result, totalCount -> AnyPublisher<Bool, Error> in
@ -1014,7 +986,7 @@ public final class MessageSender {
data: try prepareSendToSnodeDestination(
db,
message: message,
to: .contact(publicKey: userPublicKey),
to: .contact(publicKey: userPublicKey, namespace: namespace),
interactionId: interactionId,
userPublicKey: userPublicKey,
messageSendTimestamp: Int64(floor(Date().timeIntervalSince1970 * 1000)),

View File

@ -90,7 +90,7 @@ public enum PushNotificationAPI {
let url = URL(string: "\(server)/unregister")!
var request: URLRequest = URLRequest(url: url)
request.httpMethod = "POST"
request.allHTTPHeaderFields = [ Header.contentType.rawValue: "application/json" ]
request.allHTTPHeaderFields = [ HTTPHeader.contentType: "application/json" ]
request.httpBody = body
return OnionRequestAPI
@ -144,7 +144,7 @@ public enum PushNotificationAPI {
let url = URL(string: "\(server)/register")!
var request: URLRequest = URLRequest(url: url)
request.httpMethod = "POST"
request.allHTTPHeaderFields = [ Header.contentType.rawValue: "application/json" ]
request.allHTTPHeaderFields = [ HTTPHeader.contentType: "application/json" ]
request.httpBody = body
return Publishers
@ -227,7 +227,7 @@ public enum PushNotificationAPI {
let url = URL(string: "\(server)/\(operation.endpoint)")!
var request: URLRequest = URLRequest(url: url)
request.httpMethod = "POST"
request.allHTTPHeaderFields = [ Header.contentType.rawValue: "application/json" ]
request.allHTTPHeaderFields = [ HTTPHeader.contentType: "application/json" ]
request.httpBody = body
return OnionRequestAPI
@ -272,7 +272,7 @@ public enum PushNotificationAPI {
let url = URL(string: "\(server)/notify")!
var request: URLRequest = URLRequest(url: url)
request.httpMethod = "POST"
request.allHTTPHeaderFields = [ Header.contentType.rawValue: "application/json" ]
request.allHTTPHeaderFields = [ HTTPHeader.contentType: "application/json" ]
request.httpBody = body
return OnionRequestAPI

View File

@ -8,7 +8,7 @@ import SessionUtilitiesKit
extension OpenGroupAPI {
public final class Poller {
typealias PollResponse = [OpenGroupAPI.Endpoint: (info: OnionRequestResponseInfoType, data: Codable?)]
typealias PollResponse = [OpenGroupAPI.Endpoint: (info: ResponseInfoType, data: Codable?)]
private let server: String
private var timer: Timer? = nil
@ -283,7 +283,7 @@ extension OpenGroupAPI {
.filter { endpoint, endpointResponse in
switch endpoint {
case .capabilities:
guard (endpointResponse.data as? BatchSubResponse<Capabilities>)?.body != nil else {
guard (endpointResponse.data as? HTTP.BatchSubResponse<Capabilities>)?.body != nil else {
SNLog("Open group polling failed due to invalid capability data.")
return false
}
@ -291,8 +291,8 @@ extension OpenGroupAPI {
return true
case .roomPollInfo(let roomToken, _):
guard (endpointResponse.data as? BatchSubResponse<RoomPollInfo>)?.body != nil else {
switch (endpointResponse.data as? BatchSubResponse<RoomPollInfo>)?.code {
guard (endpointResponse.data as? HTTP.BatchSubResponse<RoomPollInfo>)?.body != nil else {
switch (endpointResponse.data as? HTTP.BatchSubResponse<RoomPollInfo>)?.code {
case 404: SNLog("Open group polling failed to retrieve info for unknown room '\(roomToken)'.")
default: SNLog("Open group polling failed due to invalid room info data.")
}
@ -303,10 +303,10 @@ extension OpenGroupAPI {
case .roomMessagesRecent(let roomToken), .roomMessagesBefore(let roomToken, _), .roomMessagesSince(let roomToken, _):
guard
let responseData: BatchSubResponse<[Failable<Message>]> = endpointResponse.data as? BatchSubResponse<[Failable<Message>]>,
let responseData: HTTP.BatchSubResponse<[Failable<Message>]> = endpointResponse.data as? HTTP.BatchSubResponse<[Failable<Message>]>,
let responseBody: [Failable<Message>] = responseData.body
else {
switch (endpointResponse.data as? BatchSubResponse<[Failable<Message>]>)?.code {
switch (endpointResponse.data as? HTTP.BatchSubResponse<[Failable<Message>]>)?.code {
case 404: SNLog("Open group polling failed to retrieve messages for unknown room '\(roomToken)'.")
default: SNLog("Open group polling failed due to invalid messages data.")
}
@ -325,7 +325,7 @@ extension OpenGroupAPI {
case .inbox, .inboxSince, .outbox, .outboxSince:
guard
let responseData: BatchSubResponse<[DirectMessage]?> = endpointResponse.data as? BatchSubResponse<[DirectMessage]?>,
let responseData: HTTP.BatchSubResponse<[DirectMessage]?> = endpointResponse.data as? HTTP.BatchSubResponse<[DirectMessage]?>,
!responseData.failedToParseBody
else {
SNLog("Open group polling failed due to invalid inbox/outbox data.")
@ -383,7 +383,7 @@ extension OpenGroupAPI {
switch endpoint {
case .capabilities:
guard
let responseData: BatchSubResponse<Capabilities> = endpointResponse.data as? BatchSubResponse<Capabilities>,
let responseData: HTTP.BatchSubResponse<Capabilities> = endpointResponse.data as? HTTP.BatchSubResponse<Capabilities>,
let responseBody: Capabilities = responseData.body
else { return false }
@ -391,7 +391,7 @@ extension OpenGroupAPI {
case .roomPollInfo(let roomToken, _):
guard
let responseData: BatchSubResponse<RoomPollInfo> = endpointResponse.data as? BatchSubResponse<RoomPollInfo>,
let responseData: HTTP.BatchSubResponse<RoomPollInfo> = endpointResponse.data as? HTTP.BatchSubResponse<RoomPollInfo>,
let responseBody: RoomPollInfo = responseData.body
else { return false }
guard let existingOpenGroup: OpenGroup = currentInfo?.groups.first(where: { $0.roomToken == roomToken }) else {
@ -428,7 +428,7 @@ extension OpenGroupAPI {
switch endpoint {
case .capabilities:
guard
let responseData: BatchSubResponse<Capabilities> = endpointResponse.data as? BatchSubResponse<Capabilities>,
let responseData: HTTP.BatchSubResponse<Capabilities> = endpointResponse.data as? HTTP.BatchSubResponse<Capabilities>,
let responseBody: Capabilities = responseData.body
else { return }
@ -440,7 +440,7 @@ extension OpenGroupAPI {
case .roomPollInfo(let roomToken, _):
guard
let responseData: BatchSubResponse<RoomPollInfo> = endpointResponse.data as? BatchSubResponse<RoomPollInfo>,
let responseData: HTTP.BatchSubResponse<RoomPollInfo> = endpointResponse.data as? HTTP.BatchSubResponse<RoomPollInfo>,
let responseBody: RoomPollInfo = responseData.body
else { return }
@ -455,7 +455,7 @@ extension OpenGroupAPI {
case .roomMessagesRecent(let roomToken), .roomMessagesBefore(let roomToken, _), .roomMessagesSince(let roomToken, _):
guard
let responseData: BatchSubResponse<[Failable<Message>]> = endpointResponse.data as? BatchSubResponse<[Failable<Message>]>,
let responseData: HTTP.BatchSubResponse<[Failable<Message>]> = endpointResponse.data as? HTTP.BatchSubResponse<[Failable<Message>]>,
let responseBody: [Failable<Message>] = responseData.body
else { return }
@ -469,7 +469,7 @@ extension OpenGroupAPI {
case .inbox, .inboxSince, .outbox, .outboxSince:
guard
let responseData: BatchSubResponse<[DirectMessage]?> = endpointResponse.data as? BatchSubResponse<[DirectMessage]?>,
let responseData: HTTP.BatchSubResponse<[DirectMessage]?> = endpointResponse.data as? HTTP.BatchSubResponse<[DirectMessage]?>,
!responseData.failedToParseBody
else { return }

View File

@ -191,12 +191,12 @@ public class Poller {
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
}
let pollerName: String = (
poller?.pollerName(for: publicKey) ??
"poller with public key \(publicKey)"
)
// Fetch the messages
return SnodeAPI.getMessages(in: namespaces, from: snode, associatedWith: publicKey)
.flatMap { namespacedResults -> AnyPublisher<Void, Error> in
@ -239,8 +239,8 @@ public class Poller {
}
catch {
switch error {
// Ignore duplicate & selfSend message errors (and don't bother logging
// them as there will be a lot since we each service node duplicates messages)
// Ignore duplicate & selfSend message errors (and don't bother logging
// them as there will be a lot since we each service node duplicates messages)
case DatabaseError.SQLITE_CONSTRAINT_UNIQUE,
MessageReceiverError.duplicateMessage,
MessageReceiverError.duplicateControlMessage,
@ -252,9 +252,13 @@ public class Poller {
break
case DatabaseError.SQLITE_ABORT:
SNLog("Failed to the database being suspended (running in background with no background task).")
// In the background ignore 'SQLITE_ABORT' (it generally means
// the BackgroundPoller has timed out
if !calledFromBackgroundPoller {
SNLog("Failed to the database being suspended (running in background with no background task).")
}
break
default: SNLog("Failed to deserialize envelope due to error: \(error).")
}
@ -265,33 +269,41 @@ public class Poller {
.forEach { threadId, threadMessages in
messageCount += threadMessages.count
JobRunner.add(
db,
job: Job(
variant: .messageReceive,
behaviour: .runOnce,
threadId: threadId,
details: MessageReceiveJob.Details(
messages: threadMessages.map { $0.messageInfo },
calledFromBackgroundPoller: false
)
let jobToRun: Job? = Job(
variant: .messageReceive,
behaviour: .runOnce,
threadId: threadId,
details: MessageReceiveJob.Details(
messages: threadMessages.map { $0.messageInfo },
calledFromBackgroundPoller: calledFromBackgroundPoller
)
)
jobsToRun = jobsToRun.appending(jobToRun)
// If we are force-polling then add to the JobRunner so they are
// persistent and will retry on the next app run if they fail but
// don't let them auto-start
JobRunner.add(db, job: jobToRun, canStartJob: !calledFromBackgroundPoller)
}
}
// Clean up message hashes and add some logs about the poll results
if allMessagesCount == 0 && !hadValidHashUpdate {
if !calledFromBackgroundPoller {
SNLog("Received \(allMessagesCount) new message\(allMessagesCount == 1 ? "" : "s"), all duplicates - marking the hash we polled with as invalid")
}
if messageCount == 0 && !hadValidHashUpdate, let lastHash: String = lastHash {
SNLog("Received \(messages.count) new message\(messages.count == 1 ? "" : "s"), all duplicates - marking the hash we polled with as invalid")
// Update the cached validity of the messages
try SnodeReceivedMessageInfo.handlePotentialDeletedOrInvalidHash(
db,
potentiallyInvalidHashes: [lastHash],
otherKnownValidHashes: messages.map { $0.info.hash }
)
}
else {
SNLog("Received \(messageCount) new message\(messageCount == 1 ? "" : "s") (duplicates: \(messages.count - messageCount))")
}
// Update the cached validity of the messages
try SnodeReceivedMessageInfo.handlePotentialDeletedOrInvalidHash(
db,
potentiallyInvalidHashes: lastHashes,
otherKnownValidHashes: namespacedResults
.compactMap { $0.value.data?.messages.map { $0.info.hash } }
.reduce([], +)
)
}
else if !calledFromBackgroundPoller {
SNLog("Received \(messageCount) new message\(messageCount == 1 ? "" : "s") in \(pollerName) (duplicates: \(allMessagesCount - messageCount))")
}
}

View File

@ -5,23 +5,7 @@ import SessionUtilitiesKit
// MARK: - Decoding
extension Dependencies {
static let userInfoKey: CodingUserInfoKey = CodingUserInfoKey(rawValue: "io.oxen.dependencies.codingOptions")!
}
public extension Data {
func decoded<T: Decodable>(as type: T.Type, using dependencies: Dependencies = Dependencies()) throws -> T {
do {
let decoder: JSONDecoder = JSONDecoder()
decoder.userInfo = [ Dependencies.userInfoKey: dependencies ]
return try decoder.decode(type, from: self)
}
catch {
throw HTTP.Error.parsingFailed
}
}
func removePadding() -> Data {
let bytes: [UInt8] = self.bytes
var paddingStart: Int = self.count

View File

@ -78,6 +78,13 @@ public struct ProfileManager {
return data
}
public static func hasProfileImageData(with fileName: String?) -> Bool {
guard let fileName: String = fileName, !fileName.isEmpty else { return false }
return FileManager.default
.fileExists(atPath: ProfileManager.profileAvatarFilepath(filename: fileName))
}
private static func loadProfileData(with fileName: String) -> Data? {
let filePath: String = ProfileManager.profileAvatarFilepath(filename: fileName)
@ -228,7 +235,7 @@ public struct ProfileManager {
return
}
guard let decryptedData: Data = decryptProfileData(data: data, key: profileKeyAtStart) else {
guard let decryptedData: Data = decryptData(data: data, key: profileKeyAtStart) else {
OWSLogger.warn("Avatar data for \(profile.id) could not be decrypted.")
return
}
@ -388,38 +395,22 @@ public struct ProfileManager {
return
}
// If we have no image then we should succeed (database changes happen in the callback)
guard let data: Data = avatarImageData else {
// If we have no image then we need to make sure to remove it from the profile
Storage.shared.writeAsync { db in
let existingProfile: Profile = Profile.fetchOrCreateCurrentUser(db)
OWSLogger.verbose(existingProfile.profilePictureUrl != nil ?
"Updating local profile on service with cleared avatar." :
"Updating local profile on service with no avatar."
)
let updatedProfile: Profile = try existingProfile
.with(
name: profileName,
profilePictureUrl: nil,
profilePictureFileName: nil,
profileEncryptionKey: (existingProfile.profilePictureUrl != nil ?
.update(newProfileKey) :
.existing
)
)
.saved(db)
// Remove any cached avatar image value
if let fileName: String = existingProfile.profilePictureFileName {
profileAvatarCache.mutate { $0[fileName] = nil }
// Remove any cached avatar image value
let maybeExistingFileName: String? = Storage.shared
.read { db in
try Profile
.select(.profilePictureFileName)
.asRequest(of: String.self)
.fetchOne(db)
}
SNLog("Successfully updated service with profile.")
try success?(db, updatedProfile)
if let fileName: String = maybeExistingFileName {
profileAvatarCache.mutate { $0[fileName] = nil }
}
return
return success(nil, newProfileKey)
}
// If we have a new avatar image, we must first:
@ -447,7 +438,7 @@ public struct ProfileManager {
}
// Encrypt the avatar for upload
guard let encryptedAvatarData: Data = encryptProfileData(data: data, key: newProfileKey) else {
guard let encryptedAvatarData: Data = encryptData(data: data, key: newProfileKey) else {
SNLog("Updating service with profile failed.")
failure?(.avatarEncryptionFailed)
return

View File

@ -7,7 +7,7 @@ import SignalUtilitiesKit
import SessionUIKit
import SignalCoreKit
final class ShareVC: UINavigationController, ShareViewDelegate {
final class ShareNavController: UINavigationController, ShareViewDelegate {
private var areVersionMigrationsComplete = false
public static var attachmentPrepPublisher: AnyPublisher<[SignalAttachment], Error>?
@ -183,7 +183,7 @@ final class ShareVC: UINavigationController, ShareViewDelegate {
private func showMainContent() {
let threadPickerVC: ThreadPickerVC = ThreadPickerVC()
threadPickerVC.shareVC = self
threadPickerVC.shareNavController = self
setViewControllers([ threadPickerVC ], animated: false)
@ -427,7 +427,7 @@ final class ShareVC: UINavigationController, ShareViewDelegate {
// * UTIs aren't very descriptive (there are far more MIME types than UTI types)
// so in the case of file attachments we try to refine the attachment type
// using the file extension.
guard let srcUtiType = ShareVC.utiType(itemProvider: itemProvider) else {
guard let srcUtiType = ShareNavController.utiType(itemProvider: itemProvider) else {
let error = ShareViewControllerError.unsupportedMedia
return Fail(error: error)
.eraseToAnyPublisher()
@ -613,7 +613,7 @@ final class ShareVC: UINavigationController, ShareViewDelegate {
Logger.debug("building DataSource with url: \(url), utiType: \(utiType)")
guard let dataSource = ShareVC.createDataSource(utiType: utiType, url: url, customFileName: loadedItem.customFileName) else {
guard let dataSource = ShareNavController.createDataSource(utiType: utiType, url: url, customFileName: loadedItem.customFileName) else {
let error = ShareViewControllerError.assertionError(description: "Unable to read attachment data")
return Fail(error: error)
.eraseToAnyPublisher()

View File

@ -92,12 +92,10 @@ final class SimplifiedConversationCell: UITableViewCell {
accentLineView.alpha = (cellViewModel.threadIsBlocked == true ? 1 : 0)
profilePictureView.update(
publicKey: cellViewModel.threadId,
profile: cellViewModel.profile,
additionalProfile: cellViewModel.additionalProfile,
threadVariant: cellViewModel.threadVariant,
openGroupProfilePictureData: cellViewModel.openGroupProfilePictureData,
useFallbackPicture: (cellViewModel.threadVariant == .openGroup && cellViewModel.openGroupProfilePictureData == nil),
showMultiAvatarForClosedGroup: true
customImageData: cellViewModel.openGroupProfilePictureData,
profile: cellViewModel.profile,
additionalProfile: cellViewModel.additionalProfile
)
displayNameLabel.text = cellViewModel.displayName
}

View File

@ -13,7 +13,7 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView
private var dataChangeObservable: DatabaseCancellable?
private var hasLoadedInitialData: Bool = false
var shareVC: ShareVC?
var shareNavController: ShareNavController?
// MARK: - Intialization
@ -182,9 +182,9 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView
messageText
)
shareVC?.dismiss(animated: true, completion: nil)
shareNavController?.dismiss(animated: true, completion: nil)
ModalActivityIndicatorViewController.present(fromViewController: shareVC!, canCancel: false, message: "vc_share_sending_message".localized()) { activityIndicator in
ModalActivityIndicatorViewController.present(fromViewController: shareNavController!, canCancel: false, message: "vc_share_sending_message".localized()) { activityIndicator in
// Resume database
NotificationCenter.default.post(name: Database.resumeNotification, object: self)

View File

@ -60,7 +60,7 @@ extension Snode {
}
catch {
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
public extension SnodeReceivedMessageInfo {
private static func key(for snode: Snode, publicKey: String, namespace: Int) -> String {
guard namespace != SnodeAPI.defaultNamespace else {
private static func key(for snode: Snode, publicKey: String, namespace: SnodeAPI.Namespace) -> String {
guard namespace != .default else {
return "\(snode.address):\(snode.port).\(publicKey)"
}
return "\(snode.address):\(snode.port).\(publicKey).\(namespace)"
return "\(snode.address):\(snode.port).\(publicKey).\(namespace.rawValue)"
}
init(
snode: Snode,
publicKey: String,
namespace: Int,
namespace: SnodeAPI.Namespace,
hash: String,
expirationDateMs: Int64?
) {
@ -76,15 +76,15 @@ public extension SnodeReceivedMessageInfo {
// MARK: - GRDB Interactions
public extension SnodeReceivedMessageInfo {
static func pruneExpiredMessageHashInfo(for snode: Snode, namespace: Int, associatedWith publicKey: String) {
// Delete any expired SnodeReceivedMessageInfo values associated to a specific node (even though
// this runs very quickly we fetch the rowIds we want to delete from a 'read' call to avoid
// blocking the write queue since this method is called very frequently)
static func pruneExpiredMessageHashInfo(for snode: Snode, namespace: SnodeAPI.Namespace, associatedWith publicKey: String) {
// Delete any expired SnodeReceivedMessageInfo values associated to a specific node (even
// though this runs very quickly we fetch the rowIds we want to delete from a 'read' call
// to avoid blocking the write queue since this method is called very frequently)
let rowIds: [Int64] = Storage.shared
.read { db in
// Only prune the hashes if new hashes exist for this Snode (if they don't then we don't want
// to clear out the legacy hashes)
let hasNonLegacyHash: Bool = try SnodeReceivedMessageInfo
// Only prune the hashes if new hashes exist for this Snode (if they don't then
// we don't want to clear out the legacy hashes)
let hasNonLegacyHash: Bool = SnodeReceivedMessageInfo
.filter(SnodeReceivedMessageInfo.Columns.key == key(for: snode, publicKey: publicKey, namespace: namespace))
.isNotEmpty(db)
@ -111,10 +111,10 @@ public extension SnodeReceivedMessageInfo {
/// This method fetches the last non-expired hash from the database for message retrieval
///
/// **Note:** This method uses a `write` instead of a read because there is a single write queue for the database and it's very common for
/// this method to be called after the hash value has been updated but before the various `read` threads have been updated, resulting in a
/// pointless fetch for data the app has already received
static func fetchLastNotExpired(for snode: Snode, namespace: Int, associatedWith publicKey: String) -> SnodeReceivedMessageInfo? {
/// **Note:** This method uses a `write` instead of a read because there is a single write queue for the database and it's
/// very common for this method to be called after the hash value has been updated but before the various `read` threads
/// have been updated, resulting in a pointless fetch for data the app has already received
static func fetchLastNotExpired(for snode: Snode, namespace: SnodeAPI.Namespace, associatedWith publicKey: String) -> SnodeReceivedMessageInfo? {
return Storage.shared.read { db in
let nonLegacyHash: SnodeReceivedMessageInfo? = try SnodeReceivedMessageInfo
.filter(

View File

@ -3,6 +3,7 @@
import Foundation
import Combine
import GRDB
import SignalCoreKit
import SessionUtilitiesKit
public enum GetSnodePoolJob: JobExecutor {

Some files were not shown because too many files have changed in this diff Show More