session-ios/Session/Media Viewing & Editing/DocumentTitleViewController.swift
Morgan Pretty d8fd3b35b4 Theming tweaks and bug fixes
Made a tweak to prevent some odd looking keyboard transitions when going to conversation settings
Updated the PagedDatabaseObserver to not call 'onChangeUnsorted' on the main thread (now we can generate the changeset on the background thread so there is less main thread work)
Fixed an issue where the most recently received message from the swarm could be removed from the swarm yet the app would still poll for it, resulting in the swarm always returning the oldest possible messages until the user sends a new one-to-one message
Fixed an issue where the initial scroll offset could be incorrect due to certain message types
Fixed an issue where the title view inside a conversation could jump when pushing to the conversation settings screen
Refactored a couple of ObjC functions to Swift as they were crashing (due to memory allocation?) hopefully this will fix it
Tweaked some DispatchQueue priorities to ensure PagedDatabaseObserver loading is prioritised
Updated buttons to use a standard convention for highlighted states
Updated the new conversation button to follow the new highlighted state convention
2022-10-14 17:09:38 +11:00

586 lines
22 KiB
Swift

// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import QuartzCore
import GRDB
import DifferenceKit
import SessionUIKit
import SignalUtilitiesKit
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 {
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) {
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.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.hasLoadedInitialData = true
self.viewModel.updateGalleryData(updatedGalleryData)
UIView.performWithoutAnimation {
self.tableView.reloadData()
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 static let iconImageViewSize: CGSize = CGSize(width: 31, height: 40)
private let iconImageView: UIImageView = {
let result: UIImageView = UIImageView(image: #imageLiteral(resourceName: "File").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)
}
// 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)
),
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)
),
])
}
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.sourceFilename ?? "File")
detailLabel.text = "\(OWSFormat.formatFileSize(UInt(attachment.byteCount)))"
timeLabel.text = Date(
timeIntervalSince1970: TimeInterval(item.interactionTimestampMs / 1000)
).formattedForDisplay
}
}
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)
}