mirror of https://github.com/oxen-io/session-ios
				
				
				
			
			You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
	
	
		
			416 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			Swift
		
	
			
		
		
	
	
			416 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			Swift
		
	
| // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
 | |
| 
 | |
| import Foundation
 | |
| import Combine
 | |
| import NVActivityIndicatorView
 | |
| import SessionMessagingKit
 | |
| import SessionUIKit
 | |
| 
 | |
| final class OpenGroupSuggestionGrid: UIView, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
 | |
|     private let itemsPerSection: Int = (UIDevice.current.isIPad ? 4 : 2)
 | |
|     private var maxWidth: CGFloat
 | |
|     private var data: [OpenGroupManager.DefaultRoomInfo] = [] { didSet { update() } }
 | |
|     private var heightConstraint: NSLayoutConstraint!
 | |
|     
 | |
|     var delegate: OpenGroupSuggestionGridDelegate?
 | |
|     
 | |
|     // MARK: - UI
 | |
|     
 | |
|     private static let cellHeight: CGFloat = 40
 | |
|     private static let separatorWidth = Values.separatorThickness
 | |
|     fileprivate static let numHorizontalCells: Int = (UIDevice.current.isIPad ? 4 : 2)
 | |
|     
 | |
|     private lazy var layout: LastRowCenteredLayout = {
 | |
|         let result = LastRowCenteredLayout()
 | |
|         result.minimumLineSpacing = Values.mediumSpacing
 | |
|         result.minimumInteritemSpacing = Values.mediumSpacing
 | |
|         
 | |
|         return result
 | |
|     }()
 | |
|     
 | |
|     private lazy var collectionView: UICollectionView = {
 | |
|         let result = UICollectionView(frame: .zero, collectionViewLayout: layout)
 | |
|         result.themeBackgroundColor = .clear
 | |
|         result.isScrollEnabled = false
 | |
|         result.register(view: Cell.self)
 | |
|         result.dataSource = self
 | |
|         result.delegate = self
 | |
|         
 | |
|         return result
 | |
|     }()
 | |
|     
 | |
|     private let spinner: NVActivityIndicatorView = {
 | |
|         let result: NVActivityIndicatorView = NVActivityIndicatorView(
 | |
|             frame: CGRect.zero,
 | |
|             type: .circleStrokeSpin,
 | |
|             color: .black,
 | |
|             padding: nil
 | |
|         )
 | |
|         result.set(.width, to: OpenGroupSuggestionGrid.cellHeight)
 | |
|         result.set(.height, to: OpenGroupSuggestionGrid.cellHeight)
 | |
|         
 | |
|         ThemeManager.onThemeChange(observer: result) { [weak result] theme, _ in
 | |
|             guard let textPrimary: UIColor = theme.color(for: .textPrimary) else { return }
 | |
|             
 | |
|             result?.color = textPrimary
 | |
|         }
 | |
|         
 | |
|         return result
 | |
|     }()
 | |
| 
 | |
|     private lazy var errorView: UIView = {
 | |
|         let result: UIView = UIView()
 | |
|         result.isHidden = true
 | |
|         
 | |
|         return result
 | |
|     }()
 | |
|     
 | |
|     private lazy var errorImageView: UIImageView = {
 | |
|         let result: UIImageView = UIImageView(image: #imageLiteral(resourceName: "warning").withRenderingMode(.alwaysTemplate))
 | |
|         result.themeTintColor = .danger
 | |
|         
 | |
|         return result
 | |
|     }()
 | |
|     
 | |
|     private lazy var errorTitleLabel: UILabel = {
 | |
|         let result: UILabel = UILabel()
 | |
|         result.font = .systemFont(ofSize: Values.mediumFontSize, weight: .medium)
 | |
|         result.text = "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE".localized()
 | |
|         result.themeTextColor = .textPrimary
 | |
|         result.textAlignment = .center
 | |
|         result.numberOfLines = 0
 | |
|         
 | |
|         return result
 | |
|     }()
 | |
|     
 | |
|     private lazy var errorSubtitleLabel: UILabel = {
 | |
|         let result: UILabel = UILabel()
 | |
|         result.font = .systemFont(ofSize: Values.smallFontSize, weight: .medium)
 | |
|         result.text = "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE".localized()
 | |
|         result.themeTextColor = .textPrimary
 | |
|         result.textAlignment = .center
 | |
|         result.numberOfLines = 0
 | |
|         
 | |
|         return result
 | |
|     }()
 | |
|     
 | |
|     // MARK: - Initialization
 | |
|     
 | |
|     init(maxWidth: CGFloat) {
 | |
|         self.maxWidth = maxWidth
 | |
|         
 | |
|         super.init(frame: CGRect.zero)
 | |
|         
 | |
|         initialize()
 | |
|     }
 | |
|     
 | |
|     override init(frame: CGRect) {
 | |
|         preconditionFailure("Use init(maxWidth:) instead.")
 | |
|     }
 | |
|     
 | |
|     required init?(coder: NSCoder) {
 | |
|         preconditionFailure("Use init(maxWidth:) instead.")
 | |
|     }
 | |
|     
 | |
|     private func initialize() {
 | |
|         addSubview(collectionView)
 | |
|         collectionView.pin(to: self)
 | |
|         
 | |
|         addSubview(spinner)
 | |
|         spinner.pin(.top, to: .top, of: self)
 | |
|         spinner.center(.horizontal, in: self)
 | |
|         spinner.startAnimating()
 | |
|         
 | |
|         addSubview(errorView)
 | |
|         errorView.pin(.top, to: .top, of: self, withInset: 10)
 | |
|         errorView.pin( [HorizontalEdge.leading, HorizontalEdge.trailing], to: self)
 | |
|         
 | |
|         errorView.addSubview(errorImageView)
 | |
|         errorImageView.pin(.top, to: .top, of: errorView)
 | |
|         errorImageView.center(.horizontal, in: errorView)
 | |
|         errorImageView.set(.width, to: 60)
 | |
|         errorImageView.set(.height, to: 60)
 | |
|         
 | |
|         errorView.addSubview(errorTitleLabel)
 | |
|         errorTitleLabel.pin(.top, to: .bottom, of: errorImageView, withInset: 10)
 | |
|         errorTitleLabel.center(.horizontal, in: errorView)
 | |
|         
 | |
|         errorView.addSubview(errorSubtitleLabel)
 | |
|         errorSubtitleLabel.pin(.top, to: .bottom, of: errorTitleLabel, withInset: 20)
 | |
|         errorSubtitleLabel.center(.horizontal, in: errorView)
 | |
|         
 | |
|         heightConstraint = set(.height, to: OpenGroupSuggestionGrid.cellHeight)
 | |
|         widthAnchor.constraint(greaterThanOrEqualToConstant: OpenGroupSuggestionGrid.cellHeight).isActive = true
 | |
|         
 | |
|         OpenGroupManager.getDefaultRoomsIfNeeded()
 | |
|             .subscribe(on: DispatchQueue.global(qos: .default))
 | |
|             .receive(on: DispatchQueue.main)
 | |
|             .sinkUntilComplete(
 | |
|                 receiveCompletion: { [weak self] result in
 | |
|                     switch result {
 | |
|                         case .finished: break
 | |
|                         case .failure: self?.update()
 | |
|                     }
 | |
|                 },
 | |
|                 receiveValue: { [weak self] roomInfo in self?.data = roomInfo }
 | |
|             )
 | |
|     }
 | |
|     
 | |
|     // MARK: - Updating
 | |
|     
 | |
|     private func update() {
 | |
|         spinner.stopAnimating()
 | |
|         spinner.isHidden = true
 | |
|         
 | |
|         let roomCount: CGFloat = CGFloat(min(data.count, 8)) // Cap to a maximum of 8 (4 rows of 2)
 | |
|         let numRows: CGFloat = ceil(roomCount / CGFloat(OpenGroupSuggestionGrid.numHorizontalCells))
 | |
|         let height: CGFloat = ((OpenGroupSuggestionGrid.cellHeight * numRows) + ((numRows - 1) * layout.minimumLineSpacing))
 | |
|         heightConstraint.constant = height
 | |
|         collectionView.reloadData()
 | |
|         errorView.isHidden = (roomCount > 0)
 | |
|     }
 | |
|     
 | |
|     public func refreshLayout(with maxWidth: CGFloat) {
 | |
|         self.maxWidth = maxWidth
 | |
|         collectionView.collectionViewLayout.invalidateLayout()
 | |
|     }
 | |
|     
 | |
|     // MARK: - Layout
 | |
|     
 | |
|     func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
 | |
|         let totalItems: Int = collectionView.numberOfItems(inSection: indexPath.section)
 | |
|         let itemsInFinalRow: Int = (totalItems % OpenGroupSuggestionGrid.numHorizontalCells)
 | |
|         
 | |
|         guard indexPath.item >= (totalItems - itemsInFinalRow) && itemsInFinalRow != 0 else {
 | |
|             let cellWidth: CGFloat = ((maxWidth / CGFloat(OpenGroupSuggestionGrid.numHorizontalCells)) - ((CGFloat(OpenGroupSuggestionGrid.numHorizontalCells) - 1) * layout.minimumInteritemSpacing))
 | |
|             
 | |
|             return CGSize(width: cellWidth, height: OpenGroupSuggestionGrid.cellHeight)
 | |
|         }
 | |
|         
 | |
|         // If there isn't an even number of items then we want to calculate proper sizing
 | |
|         return CGSize(
 | |
|             width: Cell.calculatedWith(for: data[indexPath.item].room.name),
 | |
|             height: OpenGroupSuggestionGrid.cellHeight
 | |
|         )
 | |
|     }
 | |
|     
 | |
|     // MARK: - Data Source
 | |
|     
 | |
|     func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
 | |
|         return min(data.count, 8) // Cap to a maximum of 8 (4 rows of 2)
 | |
|     }
 | |
|     
 | |
|     func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
 | |
|         let cell: Cell = collectionView.dequeue(type: Cell.self, for: indexPath)
 | |
|         cell.update(with: data[indexPath.item].room, existingImageData: data[indexPath.item].existingImageData)
 | |
|         
 | |
|         return cell
 | |
|     }
 | |
|     
 | |
|     // MARK: - Interaction
 | |
|     
 | |
|     func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
 | |
|         let room = data[indexPath.section * itemsPerSection + indexPath.item].room
 | |
|         delegate?.join(room)
 | |
|     }
 | |
| }
 | |
| 
 | |
| // MARK: - Cell
 | |
| 
 | |
| extension OpenGroupSuggestionGrid {
 | |
|     fileprivate final class Cell: UICollectionViewCell {
 | |
|         private static let labelFont: UIFont = .systemFont(ofSize: Values.smallFontSize)
 | |
|         private static let imageSize: CGFloat = 30
 | |
|         private static let itemPadding: CGFloat = Values.smallSpacing
 | |
|         private static let contentLeftPadding: CGFloat = 7
 | |
|         private static let contentRightPadding: CGFloat = Values.veryLargeSpacing
 | |
|         
 | |
|         fileprivate static func calculatedWith(for title: String) -> CGFloat {
 | |
|             // FIXME: Do the calculations properly in the 'LastRowCenteredLayout' to handle imageless cells
 | |
|             return (
 | |
|                 contentLeftPadding +
 | |
|                 imageSize +
 | |
|                 itemPadding +
 | |
|                 NSAttributedString(string: title, attributes: [ .font: labelFont ]).size().width +
 | |
|                 contentRightPadding +
 | |
|                 1   // Not sure why this is needed but it seems things are sometimes truncated without it
 | |
|             )
 | |
|         }
 | |
|         
 | |
|         private lazy var snContentView: UIView = {
 | |
|             let result: UIView = UIView()
 | |
|             result.themeBorderColor = .borderSeparator
 | |
|             result.layer.cornerRadius = Cell.contentViewCornerRadius
 | |
|             result.layer.borderWidth = 1
 | |
|             result.set(.height, to: Cell.contentViewHeight)
 | |
|             
 | |
|             return result
 | |
|         }()
 | |
|         
 | |
|         private lazy var imageView: UIImageView = {
 | |
|             let result: UIImageView = UIImageView()
 | |
|             result.set(.width, to: Cell.imageSize)
 | |
|             result.set(.height, to: Cell.imageSize)
 | |
|             result.layer.cornerRadius = (Cell.imageSize / 2)
 | |
|             result.clipsToBounds = true
 | |
|             
 | |
|             return result
 | |
|         }()
 | |
|         
 | |
|         private lazy var label: UILabel = {
 | |
|             let result: UILabel = UILabel()
 | |
|             result.font = Cell.labelFont
 | |
|             result.themeTextColor = .textPrimary
 | |
|             result.lineBreakMode = .byTruncatingTail
 | |
|             
 | |
|             return result
 | |
|         }()
 | |
|         
 | |
|         private static let contentViewInset: CGFloat = 0
 | |
|         private static var contentViewHeight: CGFloat { OpenGroupSuggestionGrid.cellHeight - 2 * contentViewInset }
 | |
|         private static var contentViewCornerRadius: CGFloat { contentViewHeight / 2 }
 | |
|         
 | |
|         override init(frame: CGRect) {
 | |
|             super.init(frame: frame)
 | |
|             
 | |
|             setUpViewHierarchy()
 | |
|         }
 | |
|         
 | |
|         required init?(coder: NSCoder) {
 | |
|             super.init(coder: coder)
 | |
|             
 | |
|             setUpViewHierarchy()
 | |
|         }
 | |
|         
 | |
|         private func setUpViewHierarchy() {
 | |
|             backgroundView = UIView()
 | |
|             backgroundView?.themeBackgroundColor = .backgroundPrimary
 | |
|             backgroundView?.layer.cornerRadius = Cell.contentViewCornerRadius
 | |
|             
 | |
|             selectedBackgroundView = UIView()
 | |
|             selectedBackgroundView?.themeBackgroundColor = .backgroundSecondary
 | |
|             selectedBackgroundView?.layer.cornerRadius = Cell.contentViewCornerRadius
 | |
|             
 | |
|             addSubview(snContentView)
 | |
|             
 | |
|             let stackView = UIStackView(arrangedSubviews: [ imageView, label ])
 | |
|             stackView.axis = .horizontal
 | |
|             stackView.spacing = Cell.itemPadding
 | |
|             snContentView.addSubview(stackView)
 | |
|             
 | |
|             stackView.center(.vertical, in: snContentView)
 | |
|             stackView.pin(.leading, to: .leading, of: snContentView, withInset: Cell.contentLeftPadding)
 | |
|             
 | |
|             snContentView.trailingAnchor
 | |
|                 .constraint(
 | |
|                     greaterThanOrEqualTo: stackView.trailingAnchor,
 | |
|                     constant: Cell.contentRightPadding
 | |
|                 )
 | |
|                 .isActive = true
 | |
|             snContentView.pin(to: self)
 | |
|         }
 | |
|         
 | |
|         fileprivate func update(with room: OpenGroupAPI.Room, existingImageData: Data?) {
 | |
|             label.text = room.name
 | |
|             
 | |
|             // Only continue if we have a room image
 | |
|             guard let imageId: String = room.imageId else {
 | |
|                 imageView.isHidden = true
 | |
|                 return
 | |
|             }
 | |
|             
 | |
|             imageView.image = nil
 | |
|             
 | |
|             Publishers
 | |
|                 .MergeMany(
 | |
|                     OpenGroupManager
 | |
|                         .roomImage(
 | |
|                             fileId: imageId,
 | |
|                             for: room.token,
 | |
|                             on: OpenGroupAPI.defaultServer,
 | |
|                             existingData: existingImageData
 | |
|                         )
 | |
|                         .map { ($0, true) }
 | |
|                         .eraseToAnyPublisher(),
 | |
|                     // If we have already received the room image then the above will emit first and
 | |
|                     // we can ignore this 'Just' call which is used to hide the image while loading
 | |
|                     Just((Data(), false))
 | |
|                         .setFailureType(to: Error.self)
 | |
|                         .delay(for: .milliseconds(10), scheduler: DispatchQueue.main)
 | |
|                         .eraseToAnyPublisher()
 | |
|                 )
 | |
|                 .subscribe(on: DispatchQueue.global(qos: .userInitiated))
 | |
|                 .receive(on: DispatchQueue.main)
 | |
|                 .sinkUntilComplete(
 | |
|                     receiveValue: { [weak self] imageData, hasData in
 | |
|                         guard hasData else {
 | |
|                             // This will emit twice (once with the data and once without it), if we
 | |
|                             // have actually received the images then we don't want the second emission
 | |
|                             // to hide the imageView anymore
 | |
|                             if self?.imageView.image == nil {
 | |
|                                 self?.imageView.isHidden = true
 | |
|                             }
 | |
|                             return
 | |
|                         }
 | |
|                         
 | |
|                         self?.imageView.image = UIImage(data: imageData)
 | |
|                         self?.imageView.isHidden = (self?.imageView.image == nil)
 | |
|                     }
 | |
|                 )
 | |
|         }
 | |
|     }
 | |
| }
 | |
| 
 | |
| // MARK: - Delegate
 | |
| 
 | |
| protocol OpenGroupSuggestionGridDelegate {
 | |
|     func join(_ room: OpenGroupAPI.Room)
 | |
| }
 | |
| 
 | |
| // MARK: - LastRowCenteredLayout
 | |
| 
 | |
| class LastRowCenteredLayout: UICollectionViewFlowLayout {
 | |
|     override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
 | |
|         // If we have an odd number of items then we want to center the last one horizontally
 | |
|         let elementAttributes: [UICollectionViewLayoutAttributes]? = super.layoutAttributesForElements(in: rect)
 | |
|         
 | |
|         // It looks like on "max" devices the rect we are given can be much larger than the size of the
 | |
|         // collection view, as a result we need to try and use the collectionView width here instead
 | |
|         let targetViewWidth: CGFloat = {
 | |
|             guard let collectionView: UICollectionView = self.collectionView, collectionView.frame.width > 0 else {
 | |
|                 return rect.width
 | |
|             }
 | |
|             
 | |
|             return collectionView.frame.width
 | |
|         }()
 | |
|         
 | |
|         guard
 | |
|             let remainingItems: Int = elementAttributes.map({ $0.count % OpenGroupSuggestionGrid.numHorizontalCells }),
 | |
|             remainingItems != 0,
 | |
|             let lastItems: [UICollectionViewLayoutAttributes] = elementAttributes?.suffix(remainingItems),
 | |
|             !lastItems.isEmpty
 | |
|         else { return elementAttributes }
 | |
|         
 | |
|         let totalItemWidth: CGFloat = lastItems
 | |
|             .map { $0.frame.size.width }
 | |
|             .reduce(0, +)
 | |
|         let lastRowWidth: CGFloat = (totalItemWidth + (CGFloat(lastItems.count - 1) * minimumInteritemSpacing))
 | |
|         
 | |
|         // Offset the start width by half of the remaining space
 | |
|         var itemXPos: CGFloat = ((targetViewWidth - lastRowWidth) / 2)
 | |
|         
 | |
|         lastItems.forEach { item in
 | |
|             item.frame = CGRect(
 | |
|                 x: itemXPos,
 | |
|                 y: item.frame.origin.y,
 | |
|                 width: item.frame.size.width,
 | |
|                 height: item.frame.size.height
 | |
|             )
 | |
|             
 | |
|             itemXPos += (item.frame.size.width + minimumInteritemSpacing)
 | |
|         }
 | |
|         
 | |
|         return elementAttributes
 | |
|     }
 | |
| }
 |