session-ios/Session/Media Viewing & Editing/DocumentTitleViewController...

611 lines
23 KiB
Swift

// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import QuartzCore
import GRDB
import DifferenceKit
import SessionUIKit
import SignalUtilitiesKit
import SignalCoreKit
import SessionUtilitiesKit
public class DocumentTileViewController: UIViewController, UITableViewDelegate, UITableViewDataSource {
/// This should be larger than one screen size so we don't have to call it multiple times in rapid succession, but not
/// so large that loading get's really chopping
static let itemPageSize: Int = Int(11 * itemsPerPortraitRow)
static let itemsPerPortraitRow: CGFloat = 4
static let interItemSpacing: CGFloat = 2
static let footerBarHeight: CGFloat = 40
static let loadMoreHeaderHeight: CGFloat = 100
private let viewModel: MediaGalleryViewModel
private var hasLoadedInitialData: Bool = false
private var didFinishInitialLayout: Bool = false
private var isAutoLoadingNextPage: Bool = false
private var currentTargetOffset: CGPoint?
public weak var delegate: DocumentTileViewControllerDelegate?
// MARK: - Initialization
init(viewModel: MediaGalleryViewModel) {
self.viewModel = viewModel
Storage.shared.addObserver(viewModel.pagedDataObserver)
super.init(nibName: nil, bundle: nil)
}
required public init?(coder aDecoder: NSCoder) {
notImplemented()
}
deinit {
NotificationCenter.default.removeObserver(self)
}
// MARK: - UI
override public var supportedInterfaceOrientations: UIInterfaceOrientationMask {
if UIDevice.current.isIPad {
return .all
}
return .allButUpsideDown
}
lazy var tableView: UITableView = {
let result: UITableView = UITableView()
result.themeBackgroundColor = .newConversation_background
result.separatorStyle = .none
result.showsVerticalScrollIndicator = false
result.register(view: DocumentCell.self)
result.delegate = self
result.dataSource = self
// Feels a bit weird to have content smashed all the way to the bottom edge.
result.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 20, right: 0)
if #available(iOS 15.0, *) {
result.sectionHeaderTopPadding = 0
}
return result
}()
// MARK: - Lifecycle
override public func viewDidLoad() {
super.viewDidLoad()
// Add a custom back button if this is the only view controller
if self.navigationController?.viewControllers.first == self {
let backButton = UIViewController.createOWSBackButton(target: self, selector: #selector(didPressDismissButton))
self.navigationItem.leftBarButtonItem = backButton
}
ViewControllerUtilities.setUpDefaultSessionStyle(
for: self,
title: MediaStrings.document,
hasCustomBackButton: false
)
view.addSubview(self.tableView)
tableView.autoPin(toEdgesOf: view)
// 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
)
}
public override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
startObservingChanges()
}
public override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
self.didFinishInitialLayout = true
}
public override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
stopObservingChanges()
}
@objc func applicationDidBecomeActive(_ notification: Notification) {
/// Need to dispatch to the next run loop to prevent a possible crash caused by the database resuming mid-query
DispatchQueue.main.async { [weak self] in
self?.startObservingChanges()
}
}
@objc func applicationDidResignActive(_ notification: Notification) {
stopObservingChanges()
}
// MARK: - Updating
private func performInitialScrollIfNeeded() {
// Ensure this hasn't run before and that we have data (The 'galleryData' will always
// contain something as the 'empty' state is a section within 'galleryData')
guard !self.didFinishInitialLayout && self.hasLoadedInitialData else { return }
// If we have a focused item then we want to scroll to it
guard let focusedIndexPath: IndexPath = self.viewModel.focusedIndexPath else { return }
Logger.debug("scrolling to focused item at indexPath: \(focusedIndexPath)")
self.view.layoutIfNeeded()
self.tableView.scrollToRow(at: focusedIndexPath, at: .middle, animated: false)
// Now that the data has loaded we need to check if either of the "load more" sections are
// visible and trigger them if so
//
// Note: We do it this way as we want to trigger the load behaviour for the first section
// if it has one before trying to trigger the load behaviour for the last section
self.autoLoadNextPageIfNeeded()
}
private func autoLoadNextPageIfNeeded() {
guard self.hasLoadedInitialData && !self.isAutoLoadingNextPage 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 sortedVisibleIndexPaths: [IndexPath] = (self?.tableView.indexPathsForVisibleRows ?? []).sorted()
for headerIndexPath in sortedVisibleIndexPaths {
let section: MediaGalleryViewModel.SectionModel? = self?.viewModel.galleryData[safe: headerIndexPath.section]
switch section?.model {
case .loadNewer, .loadOlder:
// Attachments are loaded in descending order so 'loadOlder' actually corresponds with
// 'pageAfter' in this case
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
self?.viewModel.pagedDataObserver?.load(section?.model == .loadOlder ?
.pageAfter :
.pageBefore
)
}
return
default: continue
}
}
}
}
private func startObservingChanges() {
// Start observing for data changes (will callback on the main thread)
self.viewModel.onGalleryChange = { [weak self] updatedGalleryData, changeset in
self?.handleUpdates(updatedGalleryData, changeset: changeset)
}
}
private func stopObservingChanges() {
// Note: The 'pagedDataObserver' will continue to get changes but
// we don't want to trigger any UI updates
self.viewModel.onGalleryChange = nil
}
private func handleUpdates(
_ updatedGalleryData: [MediaGalleryViewModel.SectionModel],
changeset: StagedChangeset<[MediaGalleryViewModel.SectionModel]>
) {
// 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 hasLoadedInitialData else {
self.viewModel.updateGalleryData(updatedGalleryData)
UIView.performWithoutAnimation {
self.tableView.reloadData()
self.hasLoadedInitialData = true
self.performInitialScrollIfNeeded()
}
return
}
let isInsertingAtTop: Bool = {
let oldFirstSectionIsLoadMore: Bool = (
self.viewModel.galleryData.first?.model == .loadNewer ||
self.viewModel.galleryData.first?.model == .loadOlder
)
let oldTargetSectionIndex: Int = (oldFirstSectionIsLoadMore ? 1 : 0)
guard
let newTargetSectionIndex = updatedGalleryData
.firstIndex(where: { $0.model == self.viewModel.galleryData[safe: oldTargetSectionIndex]?.model }),
let oldFirstItem: MediaGalleryViewModel.Item = self.viewModel.galleryData[safe: oldTargetSectionIndex]?.elements.first,
let newFirstItemIndex = updatedGalleryData[safe: newTargetSectionIndex]?.elements.firstIndex(of: oldFirstItem)
else { return false }
return (newTargetSectionIndex > oldTargetSectionIndex || newFirstItemIndex > 0)
}()
CATransaction.begin()
if isInsertingAtTop { CATransaction.setDisableActions(true) }
self.tableView.reload(
using: changeset,
with: .automatic,
interrupt: { $0.changeCount > MediaTileViewController.itemPageSize }
) { [weak self] updatedData in
self?.viewModel.updateGalleryData(updatedData)
}
CATransaction.setCompletionBlock { [weak self] in
// If one of the "load more" sections is still visible once the animation completes then
// trigger another "load more" (after a small delay to minimize animation bugginess)
self?.autoLoadNextPageIfNeeded()
}
CATransaction.commit()
}
// MARK: - Interactions
@objc public func didPressDismissButton() {
let presentedNavController: UINavigationController? = (self.presentingViewController as? UINavigationController)
let mediaPageViewController: MediaPageViewController? = (
(presentedNavController?.viewControllers.last as? MediaPageViewController) ??
(self.presentingViewController as? MediaPageViewController)
)
// If the album was presented from a 'MediaPageViewController' and it has no more data (ie.
// all album items had been deleted) then dismiss to the screen before that one
guard mediaPageViewController?.viewModel.albumData.isEmpty != true else {
presentedNavController?.presentingViewController?.dismiss(animated: true, completion: nil)
return
}
dismiss(animated: true, completion: nil)
}
// MARK: - UITableViewDataSource
public func numberOfSections(in tableView: UITableView) -> Int {
return self.viewModel.galleryData.count
}
public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return self.viewModel.galleryData[section].elements.count
}
public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell: DocumentCell = tableView.dequeue(type: DocumentCell.self, for: indexPath)
cell.update(with: self.viewModel.galleryData[indexPath.section].elements[indexPath.row])
return cell
}
// MARK: - UITableViewDelegate
public func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
let section: MediaGalleryViewModel.SectionModel = self.viewModel.galleryData[section]
switch section.model {
case .emptyGallery, .loadOlder, .loadNewer:
let headerView: DocumentStaticHeaderView = DocumentStaticHeaderView()
headerView.configure(
title: {
switch section.model {
case .emptyGallery: return "DOCUMENT_TILES_EMPTY_DOCUMENT".localized()
case .loadOlder: return "DOCUMENT_TILES_LOADING_OLDER_LABEL".localized()
case .loadNewer: return "DOCUMENT_TILES_LOADING_MORE_RECENT_LABEL".localized()
case .galleryMonth: return "" // Impossible case
}
}()
)
return headerView
case .galleryMonth(let date):
let headerView: DocumentSectionHeaderView = DocumentSectionHeaderView()
headerView.configure(title: date.localizedString)
return headerView
}
}
public func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
let section: MediaGalleryViewModel.SectionModel = self.viewModel.galleryData[section]
switch section.model {
case .emptyGallery, .loadOlder, .loadNewer:
return MediaTileViewController.loadMoreHeaderHeight
case .galleryMonth:
return 50
}
}
public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: false)
let attachment: Attachment = self.viewModel.galleryData[indexPath.section].elements[indexPath.row].attachment
guard let originalFilePath: String = attachment.originalFilePath else { return }
let fileUrl: URL = URL(fileURLWithPath: originalFilePath)
// Open a preview of the document for text, pdf or microsoft files
if
attachment.isText ||
attachment.isMicrosoftDoc ||
attachment.contentType == OWSMimeTypeApplicationPdf
{
delegate?.preview(fileUrl: fileUrl)
return
}
// Otherwise share the file
delegate?.share(fileUrl: fileUrl)
}
}
// MARK: - View
class DocumentCell: UITableViewCell {
// MARK: - Initialization
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
setUpViewHierarchy()
setupLayout()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setUpViewHierarchy()
setupLayout()
}
// MARK: - UI
private let iconImageView: UIImageView = {
let result: UIImageView = UIImageView(image: UIImage(systemName: "doc")?.withRenderingMode(.alwaysTemplate))
result.translatesAutoresizingMaskIntoConstraints = false
result.themeTintColor = .textPrimary
result.contentMode = .scaleAspectFit
return result
}()
private let audioImageView: UIImageView = {
let result = UIImageView(image: UIImage(systemName: "music.note")?.withRenderingMode(.alwaysTemplate))
result.translatesAutoresizingMaskIntoConstraints = false
result.themeTintColor = .textPrimary
result.contentMode = .scaleAspectFit
return result
}()
private let titleLabel: UILabel = {
let result: UILabel = UILabel()
result.translatesAutoresizingMaskIntoConstraints = false
result.setContentHuggingPriority(.defaultHigh, for: .horizontal)
result.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
result.font = .boldSystemFont(ofSize: Values.smallFontSize)
result.themeTextColor = .textPrimary
result.lineBreakMode = .byTruncatingTail
result.numberOfLines = 2
return result
}()
private let timeLabel: UILabel = {
let result: UILabel = UILabel()
result.translatesAutoresizingMaskIntoConstraints = false
result.setContentHuggingPriority(.defaultHigh, for: .horizontal)
result.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
result.font = .systemFont(ofSize: Values.smallFontSize)
result.themeTextColor = .textSecondary
result.lineBreakMode = .byTruncatingTail
return result
}()
private let detailLabel: UILabel = {
let result: UILabel = UILabel()
result.translatesAutoresizingMaskIntoConstraints = false
result.setContentHuggingPriority(.defaultHigh, for: .horizontal)
result.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
result.font = .systemFont(ofSize: Values.smallFontSize)
result.themeTextColor = .textSecondary
result.lineBreakMode = .byTruncatingTail
return result
}()
private func setUpViewHierarchy() {
themeBackgroundColor = .clear
backgroundView = UIView()
backgroundView?.themeBackgroundColor = .settings_tabBackground
backgroundView?.layer.cornerRadius = 5
selectedBackgroundView = UIView()
selectedBackgroundView?.themeBackgroundColor = .highlighted(.settings_tabBackground)
selectedBackgroundView?.layer.cornerRadius = 5
contentView.addSubview(iconImageView)
contentView.addSubview(titleLabel)
contentView.addSubview(timeLabel)
contentView.addSubview(detailLabel)
iconImageView.addSubview(audioImageView)
}
// MARK: - Layout
private func setupLayout() {
NSLayoutConstraint.activate([
iconImageView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
iconImageView.topAnchor.constraint(
greaterThanOrEqualTo: contentView.topAnchor,
constant: (Values.verySmallSpacing + Values.verySmallSpacing)
),
iconImageView.leftAnchor.constraint(
equalTo: contentView.leftAnchor,
constant: (Values.largeSpacing + Values.mediumSpacing)
),
iconImageView.bottomAnchor.constraint(
lessThanOrEqualTo: contentView.bottomAnchor,
constant: -(Values.verySmallSpacing + Values.verySmallSpacing)
),
iconImageView.widthAnchor.constraint(equalToConstant: 36),
iconImageView.heightAnchor.constraint(equalToConstant: 46),
titleLabel.topAnchor.constraint(
equalTo: contentView.topAnchor,
constant: (Values.verySmallSpacing + Values.verySmallSpacing)
),
titleLabel.leftAnchor.constraint(equalTo: iconImageView.rightAnchor, constant: Values.mediumSpacing),
titleLabel.rightAnchor.constraint(
lessThanOrEqualTo: timeLabel.leftAnchor,
constant: -Values.mediumSpacing
),
timeLabel.topAnchor.constraint(equalTo: iconImageView.topAnchor),
timeLabel.rightAnchor.constraint(
equalTo: contentView.rightAnchor,
constant: -(Values.mediumSpacing + Values.largeSpacing)
),
detailLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: Values.smallSpacing),
detailLabel.leftAnchor.constraint(equalTo: iconImageView.rightAnchor, constant: Values.mediumSpacing),
detailLabel.rightAnchor.constraint(
lessThanOrEqualTo: contentView.rightAnchor,
constant: -(Values.verySmallSpacing + Values.largeSpacing)
),
detailLabel.bottomAnchor.constraint(
lessThanOrEqualTo: contentView.bottomAnchor,
constant: -(Values.verySmallSpacing + Values.smallSpacing)
),
audioImageView.centerXAnchor.constraint(equalTo: iconImageView.centerXAnchor),
audioImageView.centerYAnchor.constraint(equalTo: iconImageView.centerYAnchor, constant: 7),
audioImageView.heightAnchor.constraint(equalTo: iconImageView.heightAnchor, multiplier: 0.32)
])
}
override func layoutSubviews() {
super.layoutSubviews()
backgroundView?.frame = CGRect(
x: Values.largeSpacing,
y: Values.verySmallSpacing,
width: (contentView.bounds.width - (Values.largeSpacing * 2)),
height: (contentView.bounds.height - (Values.verySmallSpacing * 2))
)
selectedBackgroundView?.frame = (backgroundView?.frame ?? .zero)
}
// MARK: - Content
func update(with item: MediaGalleryViewModel.Item) {
let attachment = item.attachment
titleLabel.text = attachment.documentFileName
detailLabel.text = attachment.documentFileInfo
timeLabel.text = Date(
timeIntervalSince1970: TimeInterval(item.interactionTimestampMs / 1000)
).formattedForDisplay
audioImageView.isHidden = !attachment.isAudio
}
}
class DocumentSectionHeaderView: UIView {
// HACK: scrollbar incorrectly appears *behind* section headers
// in collection view on iOS11 =(
private class AlwaysOnTopLayer: CALayer {
override var zPosition: CGFloat {
get { return 0 }
set {}
}
}
let label: UILabel
override class var layerClass: AnyClass {
get {
// HACK: scrollbar incorrectly appears *behind* section headers
// in collection view on iOS11 =(
return AlwaysOnTopLayer.self
}
}
override init(frame: CGRect) {
label = UILabel()
label.themeTextColor = .textPrimary
super.init(frame: frame)
self.themeBackgroundColor = .clear
let backgroundView: UIView = UIView()
backgroundView.themeBackgroundColor = .newConversation_background
addSubview(backgroundView)
backgroundView.pin(to: self)
self.addSubview(label)
label.pin(.leading, to: .leading, of: self, withInset: Values.largeSpacing)
label.pin(.trailing, to: .trailing, of: self, withInset: -Values.largeSpacing)
label.center(.vertical, in: self)
}
@available(*, unavailable, message: "Unimplemented")
required init?(coder aDecoder: NSCoder) {
notImplemented()
}
public func configure(title: String) {
self.label.text = title
}
}
class DocumentStaticHeaderView: UIView {
let label = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
addSubview(label)
label.themeTextColor = .textPrimary
label.textAlignment = .center
label.numberOfLines = 0
label.autoPinEdgesToSuperviewMargins(with: UIEdgeInsets(top: 0, leading: Values.largeSpacing, bottom: 0, trailing: Values.largeSpacing))
}
@available(*, unavailable, message: "Unimplemented")
required public init?(coder aDecoder: NSCoder) {
notImplemented()
}
public func configure(title: String) {
self.label.text = title
}
}
// MARK: - DocumentTitleViewControllerDelegate
public protocol DocumentTileViewControllerDelegate: AnyObject {
func share(fileUrl: URL)
func preview(fileUrl: URL)
}