mirror of
https://github.com/oxen-io/session-ios.git
synced 2023-12-13 21:30:14 +01:00
8c8453d922
Updated to the latest libSession version Updated the 'hidden' logic to be based on a negative 'priority' value Added an index on the Quote table to speed up conversation query Fixed an odd behaviour with GRDB and Combine (simplified the interface as well) Fixed an issue where migrations could fail
412 lines
16 KiB
Swift
412 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 rooms: [OpenGroupAPI.Room] = [] { 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()
|
|
.receive(on: DispatchQueue.main)
|
|
.sinkUntilComplete(
|
|
receiveCompletion: { [weak self] _ in self?.update() },
|
|
receiveValue: { [weak self] rooms in self?.rooms = rooms }
|
|
)
|
|
}
|
|
|
|
// MARK: - Updating
|
|
|
|
private func update() {
|
|
spinner.stopAnimating()
|
|
spinner.isHidden = true
|
|
|
|
let roomCount: CGFloat = CGFloat(min(rooms.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: rooms[indexPath.item].name),
|
|
height: OpenGroupSuggestionGrid.cellHeight
|
|
)
|
|
}
|
|
|
|
// MARK: - Data Source
|
|
|
|
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
|
|
return min(rooms.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.room = rooms[indexPath.item]
|
|
|
|
return cell
|
|
}
|
|
|
|
// MARK: - Interaction
|
|
|
|
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
|
let room = rooms[indexPath.section * itemsPerSection + indexPath.item]
|
|
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
|
|
)
|
|
}
|
|
|
|
var room: OpenGroupAPI.Room? { didSet { update() } }
|
|
|
|
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)
|
|
}
|
|
|
|
private func update() {
|
|
guard let room: OpenGroupAPI.Room = room else { return }
|
|
|
|
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(
|
|
Storage.shared
|
|
.readPublisherFlatMap { db in
|
|
OpenGroupManager
|
|
.roomImage(db, fileId: imageId, for: room.token, on: OpenGroupAPI.defaultServer)
|
|
}
|
|
.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))
|
|
.receiveOnMain(immediately: true)
|
|
.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
|
|
}
|
|
}
|