mirror of https://github.com/oxen-io/session-ios
Added in missing code changes unrelated to closed groups rebuild
parent
70ff2b49f0
commit
f1e9412c7a
File diff suppressed because it is too large
Load Diff
@ -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)
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
@ -1,22 +0,0 @@
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
|
||||
enum Header: String {
|
||||
case authorization = "Authorization"
|
||||
case contentType = "Content-Type"
|
||||
case contentDisposition = "Content-Disposition"
|
||||
|
||||
case sogsPubKey = "X-SOGS-Pubkey"
|
||||
case sogsNonce = "X-SOGS-Nonce"
|
||||
case sogsTimestamp = "X-SOGS-Timestamp"
|
||||
case sogsSignature = "X-SOGS-Signature"
|
||||
}
|
||||
|
||||
// MARK: - Convenience
|
||||
|
||||
extension Dictionary where Key == Header, Value == String {
|
||||
func toHTTPHeaders() -> [String: String] {
|
||||
return self.reduce(into: [:]) { result, next in result[next.key.rawValue] = next.value }
|
||||
}
|
||||
}
|
@ -1,15 +0,0 @@
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
|
||||
enum QueryParam: String {
|
||||
case publicKey = "public_key"
|
||||
case fromServerId = "from_server_id"
|
||||
|
||||
case required = "required"
|
||||
case limit // For messages - number between 1 and 256 (default is 100)
|
||||
case platform // For file server session version check
|
||||
case updateTypes = "t" // String indicating the types of updates that the client supports
|
||||
|
||||
case reactors = "reactors"
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,11 @@
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import SessionUtilitiesKit
|
||||
|
||||
public extension HTTPHeader {
|
||||
static let sogsPubKey: HTTPHeader = "X-SOGS-Pubkey"
|
||||
static let sogsNonce: HTTPHeader = "X-SOGS-Nonce"
|
||||
static let sogsTimestamp: HTTPHeader = "X-SOGS-Timestamp"
|
||||
static let sogsSignature: HTTPHeader = "X-SOGS-Signature"
|
||||
}
|
@ -0,0 +1,21 @@
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import SessionUtilitiesKit
|
||||
|
||||
public extension HTTPQueryParam {
|
||||
static let publicKey: HTTPQueryParam = "public_key"
|
||||
static let fromServerId: HTTPQueryParam = "from_server_id"
|
||||
|
||||
static let required: HTTPQueryParam = "required"
|
||||
|
||||
/// For messages - number between 1 and 256 (default is 100)
|
||||
static let limit: HTTPQueryParam = "limit"
|
||||
|
||||
/// For file server session version check
|
||||
static let platform: HTTPQueryParam = "platform"
|
||||
|
||||
/// String indicating the types of updates that the client supports
|
||||
static let updateTypes: HTTPQueryParam = "t"
|
||||
static let reactors: HTTPQueryParam = "reactors"
|
||||
}
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue