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.
		
		
		
		
		
			
		
			
				
	
	
		
			595 lines
		
	
	
		
			22 KiB
		
	
	
	
		
			Swift
		
	
			
		
		
	
	
			595 lines
		
	
	
		
			22 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 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 = "\(Format.fileSize(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)
 | 
						|
}
 |