mirror of
https://github.com/oxen-io/session-ios.git
synced 2023-12-13 21:30:14 +01:00
Shifted the initial HomeVC population to a background thread to avoid blocking launch processing Added some logging for database 'ABORT' errors to better identify cases of deadlocks Added a launch timeout modal to allow users to share their logs if the startup process happens to hang Updated the notification handling (and cancelling) so it could run on background threads (seemed to take up a decent chunk of main thread time) Fixed an issue where the IP2Country population was running sync which could cause a hang on startup Fixed an issue where the code checking if the UIPasteBoard contained an image was explicitly advised against by the documentation (caused some reported hangs) Fixed a hang which could be caused by a redundant function when the ImagePickerController appeared
1046 lines
40 KiB
Swift
1046 lines
40 KiB
Swift
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
|
|
|
import UIKit
|
|
import GRDB
|
|
import SessionUIKit
|
|
import SessionMessagingKit
|
|
import SignalUtilitiesKit
|
|
import SignalCoreKit
|
|
|
|
class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSource, UIPageViewControllerDelegate, MediaDetailViewControllerDelegate, InteractivelyDismissableViewController {
|
|
class DynamicallySizedView: UIView {
|
|
override var intrinsicContentSize: CGSize { CGSize.zero }
|
|
}
|
|
|
|
fileprivate var mediaInteractiveDismiss: MediaInteractiveDismiss?
|
|
|
|
public let viewModel: MediaGalleryViewModel
|
|
private var dataChangeObservable: DatabaseCancellable? {
|
|
didSet { oldValue?.cancel() } // Cancel the old observable if there was one
|
|
}
|
|
private var initialPage: MediaDetailViewController
|
|
private var cachedPages: [Int64: [MediaGalleryViewModel.Item: MediaDetailViewController]] = [:]
|
|
|
|
public var currentViewController: MediaDetailViewController {
|
|
return viewControllers!.first as! MediaDetailViewController
|
|
}
|
|
|
|
public var currentItem: MediaGalleryViewModel.Item {
|
|
return currentViewController.galleryItem
|
|
}
|
|
|
|
public func setCurrentItem(_ item: MediaGalleryViewModel.Item, direction: UIPageViewController.NavigationDirection, animated isAnimated: Bool) {
|
|
guard let galleryPage = self.buildGalleryPage(galleryItem: item) else {
|
|
owsFailDebug("unexpectedly unable to build new gallery page")
|
|
return
|
|
}
|
|
|
|
// Cache and retrieve the new album items
|
|
viewModel.loadAndCacheAlbumData(
|
|
for: item.interactionId,
|
|
in: self.viewModel.threadId
|
|
)
|
|
|
|
// Swap out the database observer
|
|
stopObservingChanges()
|
|
viewModel.replaceAlbumObservation(toObservationFor: item.interactionId)
|
|
startObservingChanges()
|
|
|
|
updateTitle(item: item)
|
|
updateCaption(item: item)
|
|
setViewControllers([galleryPage], direction: direction, animated: isAnimated)
|
|
updateFooterBarButtonItems(isPlayingVideo: false)
|
|
updateMediaRail(item: item)
|
|
}
|
|
|
|
private let showAllMediaButton: Bool
|
|
private let sliderEnabled: Bool
|
|
|
|
init(
|
|
viewModel: MediaGalleryViewModel,
|
|
initialItem: MediaGalleryViewModel.Item,
|
|
options: [MediaGalleryOption]
|
|
) {
|
|
self.viewModel = viewModel
|
|
self.showAllMediaButton = options.contains(.showAllMediaButton)
|
|
self.sliderEnabled = options.contains(.sliderEnabled)
|
|
self.initialPage = MediaDetailViewController(galleryItem: initialItem)
|
|
|
|
super.init(
|
|
transitionStyle: .scroll,
|
|
navigationOrientation: .horizontal,
|
|
options: [ .interPageSpacing: 20 ]
|
|
)
|
|
|
|
self.cachedPages[initialItem.interactionId] = [initialItem: self.initialPage]
|
|
self.initialPage.delegate = self
|
|
self.dataSource = self
|
|
self.delegate = self
|
|
self.modalPresentationStyle = .overFullScreen
|
|
self.transitioningDelegate = self
|
|
self.setViewControllers([initialPage], direction: .forward, animated: false, completion: nil)
|
|
}
|
|
|
|
@available(*, unavailable, message: "Unimplemented")
|
|
required init?(coder: NSCoder) {
|
|
notImplemented()
|
|
}
|
|
|
|
deinit {
|
|
Logger.debug("deinit")
|
|
}
|
|
|
|
// MARK: - Subview
|
|
|
|
private var hasAppeared: Bool = false
|
|
override var canBecomeFirstResponder: Bool { hasAppeared }
|
|
|
|
override var inputAccessoryView: UIView? {
|
|
return bottomContainer
|
|
}
|
|
|
|
// MARK: - Bottom Bar
|
|
|
|
var bottomContainer: UIView!
|
|
|
|
var footerBar: UIToolbar = {
|
|
let result: UIToolbar = UIToolbar()
|
|
result.clipsToBounds = true // hide 1px top-border
|
|
result.themeTintColor = .textPrimary
|
|
result.themeBarTintColor = .backgroundPrimary
|
|
result.themeBackgroundColor = .backgroundPrimary
|
|
result.setBackgroundImage(UIImage(), forToolbarPosition: .any, barMetrics: UIBarMetrics.default)
|
|
result.setShadowImage(UIImage(), forToolbarPosition: .any)
|
|
result.isTranslucent = false
|
|
|
|
return result
|
|
}()
|
|
|
|
let captionContainerView: CaptionContainerView = CaptionContainerView()
|
|
var galleryRailView: GalleryRailView = GalleryRailView()
|
|
|
|
var pagerScrollView: UIScrollView!
|
|
|
|
// MARK: UIViewController overrides
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
|
|
// Navigation
|
|
|
|
let backButton = UIViewController.createOWSBackButton(target: self, selector: #selector(didPressDismissButton))
|
|
self.navigationItem.leftBarButtonItem = backButton
|
|
self.navigationItem.titleView = portraitHeaderView
|
|
|
|
if showAllMediaButton {
|
|
self.navigationItem.rightBarButtonItem = UIBarButtonItem(title: MediaStrings.allMedia, style: .plain, target: self, action: #selector(didPressAllMediaButton))
|
|
}
|
|
|
|
// Even though bars are opaque, we want content to be layed out behind them.
|
|
// The bars might obscure part of the content, but they can easily be hidden by tapping
|
|
// The alternative would be that content would shift when the navbars hide.
|
|
self.extendedLayoutIncludesOpaqueBars = true
|
|
self.automaticallyAdjustsScrollViewInsets = false
|
|
|
|
// Disable the interactivePopGestureRecognizer as we want to be able to swipe between
|
|
// different pages
|
|
self.navigationController?.interactivePopGestureRecognizer?.isEnabled = false
|
|
self.mediaInteractiveDismiss = MediaInteractiveDismiss(targetViewController: self)
|
|
self.mediaInteractiveDismiss?.addGestureRecognizer(to: view)
|
|
|
|
// Get reference to paged content which lives in a scrollView created by the superclass
|
|
// We show/hide this content during presentation
|
|
for view in self.view.subviews {
|
|
if let pagerScrollView = view as? UIScrollView {
|
|
pagerScrollView.contentInsetAdjustmentBehavior = .never
|
|
self.pagerScrollView = pagerScrollView
|
|
}
|
|
}
|
|
|
|
// Hack to avoid "page" bouncing when not in gallery view.
|
|
// e.g. when getting to media details via message details screen, there's only
|
|
// one "Page" so the bounce doesn't make sense.
|
|
pagerScrollView.isScrollEnabled = sliderEnabled
|
|
pagerScrollViewContentOffsetObservation = pagerScrollView.observe(\.contentOffset, options: [.new]) { [weak self] _, change in
|
|
guard let strongSelf = self else { return }
|
|
strongSelf.pagerScrollView(strongSelf.pagerScrollView, contentOffsetDidChange: change)
|
|
}
|
|
|
|
// Views
|
|
pagerScrollView.themeBackgroundColor = .newConversation_background
|
|
|
|
view.themeBackgroundColor = .newConversation_background
|
|
|
|
captionContainerView.delegate = self
|
|
updateCaptionContainerVisibility()
|
|
|
|
galleryRailView.isHidden = true
|
|
galleryRailView.delegate = self
|
|
galleryRailView.autoSetDimension(.height, toSize: 72)
|
|
footerBar.autoSetDimension(.height, toSize: 44)
|
|
|
|
let bottomContainer: DynamicallySizedView = DynamicallySizedView()
|
|
bottomContainer.clipsToBounds = true
|
|
bottomContainer.autoresizingMask = .flexibleHeight
|
|
bottomContainer.themeBackgroundColor = .backgroundPrimary
|
|
self.bottomContainer = bottomContainer
|
|
|
|
let bottomStack = UIStackView(arrangedSubviews: [captionContainerView, galleryRailView, footerBar])
|
|
bottomStack.axis = .vertical
|
|
bottomStack.isLayoutMarginsRelativeArrangement = true
|
|
bottomContainer.addSubview(bottomStack)
|
|
bottomStack.autoPinEdgesToSuperviewEdges()
|
|
|
|
let galleryRailBlockingView: UIView = UIView()
|
|
galleryRailBlockingView.themeBackgroundColor = .backgroundPrimary
|
|
bottomStack.addSubview(galleryRailBlockingView)
|
|
galleryRailBlockingView.pin(.top, to: .bottom, of: footerBar)
|
|
galleryRailBlockingView.pin(.left, to: .left, of: bottomStack)
|
|
galleryRailBlockingView.pin(.right, to: .right, of: bottomStack)
|
|
galleryRailBlockingView.pin(.bottom, to: .bottom, of: bottomStack)
|
|
|
|
updateTitle(item: currentItem)
|
|
updateCaption(item: currentItem)
|
|
updateMediaRail(item: currentItem)
|
|
updateFooterBarButtonItems(isPlayingVideo: false)
|
|
|
|
// Gestures
|
|
|
|
let verticalSwipe = UISwipeGestureRecognizer(target: self, action: #selector(didSwipeView))
|
|
verticalSwipe.direction = [.up, .down]
|
|
view.addGestureRecognizer(verticalSwipe)
|
|
|
|
// 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()
|
|
}
|
|
|
|
override func viewDidAppear(_ animated: Bool) {
|
|
super.viewDidAppear(animated)
|
|
|
|
hasAppeared = true
|
|
becomeFirstResponder()
|
|
}
|
|
|
|
public override func viewWillDisappear(_ animated: Bool) {
|
|
super.viewWillDisappear(animated)
|
|
|
|
stopObservingChanges()
|
|
|
|
resignFirstResponder()
|
|
}
|
|
|
|
@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()
|
|
}
|
|
|
|
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
|
|
super.viewWillTransition(to: size, with: coordinator)
|
|
let isLandscape = size.width > size.height
|
|
self.navigationItem.titleView = isLandscape ? nil : self.portraitHeaderView
|
|
}
|
|
|
|
override func didReceiveMemoryWarning() {
|
|
Logger.info("")
|
|
super.didReceiveMemoryWarning()
|
|
|
|
self.cachedPages = [:]
|
|
}
|
|
|
|
// MARK: KVO
|
|
|
|
var pagerScrollViewContentOffsetObservation: NSKeyValueObservation?
|
|
func pagerScrollView(_ pagerScrollView: UIScrollView, contentOffsetDidChange change: NSKeyValueObservedChange<CGPoint>) {
|
|
guard let newValue = change.newValue else {
|
|
owsFailDebug("newValue was unexpectedly nil")
|
|
return
|
|
}
|
|
|
|
let width = pagerScrollView.frame.size.width
|
|
guard width > 0 else {
|
|
return
|
|
}
|
|
let ratioComplete = abs((newValue.x - width) / width)
|
|
captionContainerView.updatePagerTransition(ratioComplete: ratioComplete)
|
|
}
|
|
|
|
// MARK: View Helpers
|
|
|
|
public func willBePresentedAgain() {
|
|
updateFooterBarButtonItems(isPlayingVideo: false)
|
|
}
|
|
|
|
public func wasPresented() {
|
|
let currentViewController = self.currentViewController
|
|
|
|
if currentViewController.galleryItem.isVideo {
|
|
currentViewController.playVideo()
|
|
}
|
|
}
|
|
|
|
private var shouldHideToolbars: Bool = false {
|
|
didSet {
|
|
guard oldValue != shouldHideToolbars else { return }
|
|
|
|
self.navigationController?.setNavigationBarHidden(shouldHideToolbars, animated: false)
|
|
|
|
UIView.animate(withDuration: 0.1) {
|
|
self.currentViewController.setShouldHideToolbars(self.shouldHideToolbars)
|
|
self.bottomContainer.isHidden = self.shouldHideToolbars
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: Bar Buttons
|
|
|
|
lazy var shareBarButton: UIBarButtonItem = {
|
|
let shareBarButton = UIBarButtonItem(
|
|
barButtonSystemItem: .action,
|
|
target: self,
|
|
action: #selector(didPressShare)
|
|
)
|
|
shareBarButton.themeTintColor = .textPrimary
|
|
|
|
return shareBarButton
|
|
}()
|
|
|
|
lazy var deleteBarButton: UIBarButtonItem = {
|
|
let deleteBarButton = UIBarButtonItem(
|
|
barButtonSystemItem: .trash,
|
|
target: self,
|
|
action: #selector(didPressDelete)
|
|
)
|
|
deleteBarButton.themeTintColor = .textPrimary
|
|
|
|
return deleteBarButton
|
|
}()
|
|
|
|
func buildFlexibleSpace() -> UIBarButtonItem {
|
|
return UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil)
|
|
}
|
|
|
|
lazy var videoPlayBarButton: UIBarButtonItem = {
|
|
let videoPlayBarButton = UIBarButtonItem(
|
|
barButtonSystemItem: .play,
|
|
target: self,
|
|
action: #selector(didPressPlayBarButton)
|
|
)
|
|
videoPlayBarButton.themeTintColor = .textPrimary
|
|
|
|
return videoPlayBarButton
|
|
}()
|
|
|
|
lazy var videoPauseBarButton: UIBarButtonItem = {
|
|
let videoPauseBarButton = UIBarButtonItem(
|
|
barButtonSystemItem: .pause,
|
|
target: self,
|
|
action: #selector(didPressPauseBarButton)
|
|
)
|
|
videoPauseBarButton.themeTintColor = .textPrimary
|
|
|
|
return videoPauseBarButton
|
|
}()
|
|
|
|
private func updateFooterBarButtonItems(isPlayingVideo: Bool) {
|
|
self.footerBar.setItems(
|
|
[
|
|
shareBarButton,
|
|
buildFlexibleSpace(),
|
|
(self.currentItem.isVideo && isPlayingVideo ? self.videoPauseBarButton : nil),
|
|
(self.currentItem.isVideo && !isPlayingVideo ? self.videoPlayBarButton : nil),
|
|
(self.currentItem.isVideo ? buildFlexibleSpace() : nil),
|
|
deleteBarButton
|
|
].compactMap { $0 },
|
|
animated: false
|
|
)
|
|
}
|
|
|
|
func updateMediaRail(item: MediaGalleryViewModel.Item) {
|
|
galleryRailView.configureCellViews(
|
|
album: (self.viewModel.albumData[item.interactionId] ?? []),
|
|
focusedItem: currentItem,
|
|
cellViewBuilder: { _ in return GalleryRailCellView() }
|
|
)
|
|
}
|
|
|
|
// MARK: - Updating
|
|
|
|
private func startObservingChanges() {
|
|
guard dataChangeObservable == nil else { return }
|
|
|
|
// Start observing for data changes
|
|
dataChangeObservable = Storage.shared.start(
|
|
viewModel.observableAlbumData,
|
|
onError: { _ in },
|
|
onChange: { [weak self] albumData in
|
|
// The default scheduler emits changes on the main thread
|
|
self?.handleUpdates(albumData)
|
|
}
|
|
)
|
|
}
|
|
|
|
private func stopObservingChanges() {
|
|
dataChangeObservable = nil
|
|
}
|
|
|
|
private func handleUpdates(_ updatedViewData: [MediaGalleryViewModel.Item]) {
|
|
// Determine if we swapped albums (if so we don't need to do anything else)
|
|
guard updatedViewData.contains(where: { $0.interactionId == currentItem.interactionId }) else {
|
|
if let updatedInteractionId: Int64 = updatedViewData.first?.interactionId {
|
|
self.viewModel.updateAlbumData(updatedViewData, for: updatedInteractionId)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Clear the cached pages that no longer match
|
|
let interactionId: Int64 = currentItem.interactionId
|
|
let updatedCachedPages: [MediaGalleryViewModel.Item: MediaDetailViewController] = cachedPages[interactionId]
|
|
.defaulting(to: [:])
|
|
.filter { key, _ -> Bool in updatedViewData.contains(key) }
|
|
|
|
// If there are no more items in the album then dismiss the screen
|
|
guard
|
|
!updatedViewData.isEmpty,
|
|
let oldIndex: Int = self.viewModel.albumData[interactionId]?.firstIndex(of: currentItem)
|
|
else {
|
|
self.dismissSelf(animated: true)
|
|
return
|
|
}
|
|
|
|
// Update the caches
|
|
self.viewModel.updateAlbumData(updatedViewData, for: interactionId)
|
|
self.cachedPages[interactionId] = updatedCachedPages
|
|
|
|
// If the current item is still available then do nothing else
|
|
guard updatedCachedPages[currentItem] == nil else { return }
|
|
|
|
// If the current item was modified within the current update then reload it (just in case)
|
|
if let updatedCurrentItem: MediaGalleryViewModel.Item = updatedViewData.first(where: { item in item.attachment.id == currentItem.attachment.id }) {
|
|
setCurrentItem(updatedCurrentItem, direction: .forward, animated: false)
|
|
return
|
|
}
|
|
|
|
// Determine the next index (if it's less than 0 then pop the screen)
|
|
let nextIndex: Int = min(oldIndex, (updatedViewData.count - 1))
|
|
|
|
guard nextIndex >= 0 else {
|
|
self.dismissSelf(animated: true)
|
|
return
|
|
}
|
|
|
|
self.setCurrentItem(
|
|
updatedViewData[nextIndex],
|
|
direction: (nextIndex < oldIndex ?
|
|
.reverse :
|
|
.forward
|
|
),
|
|
animated: true
|
|
)
|
|
}
|
|
|
|
// MARK: - Actions
|
|
|
|
@objc public func didPressAllMediaButton(sender: Any) {
|
|
currentViewController.stopAnyVideo()
|
|
|
|
// If the screen wasn't presented or it was presented from a location which isn't the
|
|
// MediaTileViewController then just pop/dismiss the screen
|
|
guard
|
|
let presentingNavController: UINavigationController = (self.presentingViewController as? UINavigationController),
|
|
!(presentingNavController.viewControllers.last is AllMediaViewController)
|
|
else {
|
|
guard self.navigationController?.viewControllers.count == 1 else {
|
|
self.navigationController?.popViewController(animated: true)
|
|
return
|
|
}
|
|
|
|
self.dismiss(animated: true)
|
|
return
|
|
}
|
|
|
|
// Otherwise if we came via the conversation screen we need to push a new
|
|
// instance of MediaTileViewController
|
|
let allMediaViewController: AllMediaViewController = MediaGalleryViewModel.createAllMediaViewController(
|
|
threadId: self.viewModel.threadId,
|
|
threadVariant: self.viewModel.threadVariant,
|
|
focusedAttachmentId: currentItem.attachment.id,
|
|
performInitialQuerySync: true
|
|
)
|
|
|
|
let navController: MediaGalleryNavigationController = MediaGalleryNavigationController()
|
|
navController.viewControllers = [allMediaViewController]
|
|
navController.modalPresentationStyle = .overFullScreen
|
|
navController.transitioningDelegate = allMediaViewController
|
|
|
|
self.navigationController?.present(navController, animated: true)
|
|
}
|
|
|
|
@objc public func didSwipeView(sender: Any) {
|
|
self.dismissSelf(animated: true)
|
|
}
|
|
|
|
@objc public func didPressDismissButton(_ sender: Any) {
|
|
dismissSelf(animated: true)
|
|
}
|
|
|
|
@objc
|
|
public func didPressShare(_ sender: Any) {
|
|
guard let currentViewController = self.viewControllers?[0] as? MediaDetailViewController else {
|
|
owsFailDebug("currentViewController was unexpectedly nil")
|
|
return
|
|
}
|
|
guard let originalFilePath: String = currentViewController.galleryItem.attachment.originalFilePath else {
|
|
return
|
|
}
|
|
|
|
let shareVC = UIActivityViewController(activityItems: [ URL(fileURLWithPath: originalFilePath) ], applicationActivities: nil)
|
|
|
|
if UIDevice.current.isIPad {
|
|
shareVC.excludedActivityTypes = []
|
|
shareVC.popoverPresentationController?.permittedArrowDirections = []
|
|
shareVC.popoverPresentationController?.sourceView = self.view
|
|
shareVC.popoverPresentationController?.sourceRect = self.view.bounds
|
|
}
|
|
|
|
shareVC.completionWithItemsHandler = { activityType, completed, returnedItems, activityError in
|
|
if let activityError = activityError {
|
|
SNLog("Failed to share with activityError: \(activityError)")
|
|
}
|
|
else if completed {
|
|
SNLog("Did share with activityType: \(activityType.debugDescription)")
|
|
}
|
|
|
|
guard
|
|
let activityType = activityType,
|
|
activityType == .saveToCameraRoll,
|
|
currentViewController.galleryItem.interactionVariant == .standardIncoming,
|
|
self.viewModel.threadVariant == .contact
|
|
else { return }
|
|
|
|
let threadId: String = self.viewModel.threadId
|
|
let threadVariant: SessionThread.Variant = self.viewModel.threadVariant
|
|
|
|
Storage.shared.write { db in
|
|
try MessageSender.send(
|
|
db,
|
|
message: DataExtractionNotification(
|
|
kind: .mediaSaved(
|
|
timestamp: UInt64(currentViewController.galleryItem.interactionTimestampMs)
|
|
),
|
|
sentTimestamp: UInt64(SnodeAPI.currentOffsetTimestampMs())
|
|
),
|
|
interactionId: nil, // Show no interaction for the current user
|
|
threadId: threadId,
|
|
threadVariant: threadVariant
|
|
)
|
|
}
|
|
}
|
|
self.present(shareVC, animated: true, completion: nil)
|
|
}
|
|
|
|
@objc public func didPressDelete(_ sender: Any) {
|
|
let itemToDelete: MediaGalleryViewModel.Item = self.currentItem
|
|
let actionSheet: UIAlertController = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)
|
|
let deleteAction = UIAlertAction(
|
|
title: "delete_message_for_me".localized(),
|
|
style: .destructive
|
|
) { _ in
|
|
Storage.shared.writeAsync { db in
|
|
_ = try Attachment
|
|
.filter(id: itemToDelete.attachment.id)
|
|
.deleteAll(db)
|
|
|
|
// Add the garbage collection job to delete orphaned attachment files
|
|
JobRunner.add(
|
|
db,
|
|
job: Job(
|
|
variant: .garbageCollection,
|
|
behaviour: .runOnce,
|
|
details: GarbageCollectionJob.Details(
|
|
typesToCollect: [.orphanedAttachmentFiles]
|
|
)
|
|
)
|
|
)
|
|
|
|
// Delete any interactions which had all of their attachments removed
|
|
_ = try Interaction
|
|
.filter(id: itemToDelete.interactionId)
|
|
.having(Interaction.interactionAttachments.isEmpty)
|
|
.deleteAll(db)
|
|
}
|
|
}
|
|
actionSheet.addAction(UIAlertAction(title: "TXT_CANCEL_TITLE".localized(), style: .cancel))
|
|
actionSheet.addAction(deleteAction)
|
|
|
|
Modal.setupForIPadIfNeeded(actionSheet, targetView: self.view)
|
|
self.present(actionSheet, animated: true)
|
|
}
|
|
|
|
// MARK: - Video interaction
|
|
|
|
@objc public func didPressPlayBarButton() {
|
|
guard let currentViewController = self.viewControllers?.first as? MediaDetailViewController else {
|
|
SNLog("currentViewController was unexpectedly nil")
|
|
return
|
|
}
|
|
|
|
currentViewController.didPressPlayBarButton()
|
|
}
|
|
|
|
@objc public func didPressPauseBarButton() {
|
|
guard let currentViewController = self.viewControllers?.first as? MediaDetailViewController else {
|
|
SNLog("currentViewController was unexpectedly nil")
|
|
return
|
|
}
|
|
|
|
currentViewController.didPressPauseBarButton()
|
|
}
|
|
|
|
// MARK: UIPageViewControllerDelegate
|
|
|
|
var pendingViewController: MediaDetailViewController?
|
|
public func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) {
|
|
Logger.debug("")
|
|
|
|
assert(pendingViewControllers.count == 1)
|
|
pendingViewControllers.forEach { viewController in
|
|
guard let pendingViewController = viewController as? MediaDetailViewController else {
|
|
owsFailDebug("unexpected mediaDetailViewController: \(viewController)")
|
|
return
|
|
}
|
|
self.pendingViewController = pendingViewController
|
|
|
|
if let pendingCaptionText = pendingViewController.galleryItem.captionForDisplay, pendingCaptionText.count > 0 {
|
|
self.captionContainerView.pendingText = pendingCaptionText
|
|
} else {
|
|
self.captionContainerView.pendingText = nil
|
|
}
|
|
|
|
// Ensure upcoming page respects current toolbar status
|
|
pendingViewController.setShouldHideToolbars(self.shouldHideToolbars)
|
|
}
|
|
}
|
|
|
|
public func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted: Bool) {
|
|
Logger.debug("")
|
|
|
|
assert(previousViewControllers.count == 1)
|
|
previousViewControllers.forEach { viewController in
|
|
guard let previousPage = viewController as? MediaDetailViewController else {
|
|
owsFailDebug("unexpected mediaDetailViewController: \(viewController)")
|
|
return
|
|
}
|
|
|
|
// Do any cleanup for the no-longer visible view controller
|
|
if transitionCompleted {
|
|
pendingViewController = nil
|
|
|
|
// This can happen when trying to page past the last (or first) view controller
|
|
// In that case, we don't want to change the captionView.
|
|
if (previousPage != currentViewController) {
|
|
captionContainerView.completePagerTransition()
|
|
}
|
|
|
|
updateTitle(item: currentItem)
|
|
updateMediaRail(item: currentItem)
|
|
previousPage.zoomOut(animated: false)
|
|
previousPage.stopAnyVideo()
|
|
updateFooterBarButtonItems(isPlayingVideo: false)
|
|
} else {
|
|
captionContainerView.pendingText = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: UIPageViewControllerDataSource
|
|
|
|
public func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
|
|
guard let mediaViewController: MediaDetailViewController = viewController as? MediaDetailViewController else {
|
|
return nil
|
|
}
|
|
|
|
// First check if there is another item in the current album
|
|
let interactionId: Int64 = mediaViewController.galleryItem.interactionId
|
|
|
|
if
|
|
let currentAlbum: [MediaGalleryViewModel.Item] = self.viewModel.albumData[interactionId],
|
|
let index: Int = currentAlbum.firstIndex(of: mediaViewController.galleryItem),
|
|
index > 0,
|
|
let previousPage: MediaDetailViewController = buildGalleryPage(galleryItem: currentAlbum[index - 1])
|
|
{
|
|
return previousPage
|
|
}
|
|
|
|
// Then check if there is an interaction before the current album interaction
|
|
guard let interactionIdAfter: Int64 = self.viewModel.interactionIdAfter[interactionId] else {
|
|
return nil
|
|
}
|
|
|
|
// Cache and retrieve the new album items
|
|
let newAlbumItems: [MediaGalleryViewModel.Item] = viewModel.loadAndCacheAlbumData(
|
|
for: interactionIdAfter,
|
|
in: self.viewModel.threadId
|
|
)
|
|
|
|
guard
|
|
!newAlbumItems.isEmpty,
|
|
let previousPage: MediaDetailViewController = buildGalleryPage(
|
|
galleryItem: newAlbumItems[newAlbumItems.count - 1]
|
|
)
|
|
else {
|
|
// Invalid state, restart the observer
|
|
startObservingChanges()
|
|
return nil
|
|
}
|
|
|
|
// Swap out the database observer
|
|
stopObservingChanges()
|
|
viewModel.replaceAlbumObservation(toObservationFor: interactionIdAfter)
|
|
startObservingChanges()
|
|
|
|
return previousPage
|
|
}
|
|
|
|
public func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
|
|
guard let mediaViewController: MediaDetailViewController = viewController as? MediaDetailViewController else {
|
|
return nil
|
|
}
|
|
|
|
// First check if there is another item in the current album
|
|
let interactionId: Int64 = mediaViewController.galleryItem.interactionId
|
|
|
|
if
|
|
let currentAlbum: [MediaGalleryViewModel.Item] = self.viewModel.albumData[interactionId],
|
|
let index: Int = currentAlbum.firstIndex(of: mediaViewController.galleryItem),
|
|
index < (currentAlbum.count - 1),
|
|
let nextPage: MediaDetailViewController = buildGalleryPage(galleryItem: currentAlbum[index + 1])
|
|
{
|
|
return nextPage
|
|
}
|
|
|
|
// Then check if there is an interaction before the current album interaction
|
|
guard let interactionIdBefore: Int64 = self.viewModel.interactionIdBefore[interactionId] else {
|
|
return nil
|
|
}
|
|
|
|
// Cache and retrieve the new album items
|
|
let newAlbumItems: [MediaGalleryViewModel.Item] = viewModel.loadAndCacheAlbumData(
|
|
for: interactionIdBefore,
|
|
in: self.viewModel.threadId
|
|
)
|
|
|
|
guard
|
|
!newAlbumItems.isEmpty,
|
|
let nextPage: MediaDetailViewController = buildGalleryPage(galleryItem: newAlbumItems[0])
|
|
else {
|
|
// Invalid state, restart the observer
|
|
startObservingChanges()
|
|
return nil
|
|
}
|
|
|
|
// Swap out the database observer
|
|
stopObservingChanges()
|
|
viewModel.replaceAlbumObservation(toObservationFor: interactionIdBefore)
|
|
startObservingChanges()
|
|
|
|
return nextPage
|
|
}
|
|
|
|
private func buildGalleryPage(galleryItem: MediaGalleryViewModel.Item) -> MediaDetailViewController? {
|
|
if let cachedPage: MediaDetailViewController = cachedPages[galleryItem.interactionId]?[galleryItem] {
|
|
return cachedPage
|
|
}
|
|
|
|
cachedPages[galleryItem.interactionId] = (cachedPages[galleryItem.interactionId] ?? [:])
|
|
.setting(galleryItem, MediaDetailViewController(galleryItem: galleryItem, delegate: self))
|
|
|
|
return cachedPages[galleryItem.interactionId]?[galleryItem]
|
|
}
|
|
|
|
public func dismissSelf(animated isAnimated: Bool, completion: (() -> Void)? = nil) {
|
|
// If we have presented a MediaTileViewController from this screen then it will continue
|
|
// to observe media changes and if all the items in the album this screen is showing are
|
|
// deleted it will attempt to auto-dismiss
|
|
guard self.presentedViewController == nil else { return }
|
|
|
|
// Swapping mediaView for presentationView will be perceptible if we're not zoomed out all the way.
|
|
// currentVC
|
|
currentViewController.zoomOut(animated: true)
|
|
currentViewController.stopAnyVideo()
|
|
|
|
self.navigationController?.view.isUserInteractionEnabled = false
|
|
self.navigationController?.dismiss(animated: true, completion: { [weak self] in
|
|
if !IsLandscapeOrientationEnabled() {
|
|
UIDevice.current.ows_setOrientation(.portrait)
|
|
}
|
|
|
|
UIApplication.shared.isStatusBarHidden = false
|
|
self?.navigationController?.presentingViewController?.setNeedsStatusBarAppearanceUpdate()
|
|
completion?()
|
|
})
|
|
}
|
|
|
|
// MARK: MediaDetailViewControllerDelegate
|
|
|
|
public func mediaDetailViewControllerDidTapMedia(_ mediaDetailViewController: MediaDetailViewController) {
|
|
Logger.debug("")
|
|
|
|
self.shouldHideToolbars = !self.shouldHideToolbars
|
|
}
|
|
|
|
public func mediaDetailViewController(_ mediaDetailViewController: MediaDetailViewController, isPlayingVideo: Bool) {
|
|
guard mediaDetailViewController == currentViewController else {
|
|
Logger.verbose("ignoring stale delegate.")
|
|
return
|
|
}
|
|
|
|
self.shouldHideToolbars = isPlayingVideo
|
|
self.updateFooterBarButtonItems(isPlayingVideo: isPlayingVideo)
|
|
}
|
|
|
|
// MARK: - Dynamic Header
|
|
|
|
private lazy var dateFormatter: DateFormatter = {
|
|
let formatter = DateFormatter()
|
|
formatter.dateStyle = .short
|
|
formatter.timeStyle = .short
|
|
|
|
return formatter
|
|
}()
|
|
|
|
lazy private var portraitHeaderNameLabel: UILabel = {
|
|
let label: UILabel = UILabel()
|
|
label.font = .systemFont(ofSize: Values.mediumFontSize)
|
|
label.themeTextColor = .textPrimary
|
|
label.textAlignment = .center
|
|
label.adjustsFontSizeToFitWidth = true
|
|
label.minimumScaleFactor = 0.8
|
|
|
|
return label
|
|
}()
|
|
|
|
lazy private var portraitHeaderDateLabel: UILabel = {
|
|
let label: UILabel = UILabel()
|
|
label.font = .systemFont(ofSize: Values.verySmallFontSize)
|
|
label.themeTextColor = .textPrimary
|
|
label.textAlignment = .center
|
|
label.adjustsFontSizeToFitWidth = true
|
|
label.minimumScaleFactor = 0.8
|
|
|
|
return label
|
|
}()
|
|
|
|
private lazy var portraitHeaderView: UIView = {
|
|
let stackView: UIStackView = UIStackView()
|
|
stackView.axis = .vertical
|
|
stackView.alignment = .center
|
|
stackView.spacing = 0
|
|
stackView.distribution = .fillProportionally
|
|
stackView.addArrangedSubview(portraitHeaderNameLabel)
|
|
stackView.addArrangedSubview(portraitHeaderDateLabel)
|
|
|
|
let containerView = UIView()
|
|
containerView.layoutMargins = UIEdgeInsets(top: 2, left: 8, bottom: 4, right: 8)
|
|
|
|
containerView.addSubview(stackView)
|
|
|
|
stackView.autoPinEdge(toSuperviewMargin: .top, relation: .greaterThanOrEqual)
|
|
stackView.autoPinEdge(toSuperviewMargin: .trailing, relation: .greaterThanOrEqual)
|
|
stackView.autoPinEdge(toSuperviewMargin: .bottom, relation: .greaterThanOrEqual)
|
|
stackView.autoPinEdge(toSuperviewMargin: .leading, relation: .greaterThanOrEqual)
|
|
stackView.setContentHuggingHigh()
|
|
stackView.autoCenterInSuperview()
|
|
|
|
return containerView
|
|
}()
|
|
|
|
private func updateCaption(item: MediaGalleryViewModel.Item) {
|
|
captionContainerView.currentText = item.captionForDisplay
|
|
}
|
|
|
|
private func updateTitle(item: MediaGalleryViewModel.Item) {
|
|
let targetItem: MediaGalleryViewModel.Item = item
|
|
let threadVariant: SessionThread.Variant = self.viewModel.threadVariant
|
|
|
|
let name: String = {
|
|
switch targetItem.interactionVariant {
|
|
case .standardIncoming:
|
|
return Storage.shared
|
|
.read { db in
|
|
Profile.displayName(
|
|
db,
|
|
id: targetItem.interactionAuthorId,
|
|
threadVariant: threadVariant
|
|
)
|
|
}
|
|
.defaulting(to: Profile.truncated(id: targetItem.interactionAuthorId, truncating: .middle))
|
|
|
|
case .standardOutgoing:
|
|
return "MEDIA_GALLERY_SENDER_NAME_YOU".localized() //"Short sender label for media sent by you"
|
|
|
|
default:
|
|
owsFailDebug("Unsupported message variant: \(targetItem.interactionVariant)")
|
|
return ""
|
|
}
|
|
}()
|
|
|
|
portraitHeaderNameLabel.text = name
|
|
|
|
// use sent date
|
|
let date = Date(timeIntervalSince1970: (Double(targetItem.interactionTimestampMs) / 1000))
|
|
let formattedDate = dateFormatter.string(from: date)
|
|
portraitHeaderDateLabel.text = formattedDate
|
|
|
|
let landscapeHeaderFormat = NSLocalizedString("MEDIA_GALLERY_LANDSCAPE_TITLE_FORMAT", comment: "embeds {{sender name}} and {{sent datetime}}, e.g. 'Sarah on 10/30/18, 3:29'")
|
|
let landscapeHeaderText = String(format: landscapeHeaderFormat, name, formattedDate)
|
|
self.title = landscapeHeaderText
|
|
self.navigationItem.title = landscapeHeaderText
|
|
}
|
|
|
|
// MARK: - InteractivelyDismissableViewController
|
|
|
|
func performInteractiveDismissal(animated: Bool) {
|
|
dismissSelf(animated: true)
|
|
}
|
|
}
|
|
|
|
extension MediaGalleryViewModel.Item: GalleryRailItem {
|
|
public func buildRailItemView() -> UIView {
|
|
let imageView: UIImageView = UIImageView()
|
|
imageView.contentMode = .scaleAspectFill
|
|
|
|
self.thumbnailImage { [weak imageView] image in
|
|
DispatchQueue.main.async {
|
|
imageView?.image = image
|
|
}
|
|
}
|
|
|
|
return imageView
|
|
}
|
|
|
|
public func isEqual(to other: GalleryRailItem?) -> Bool {
|
|
guard let otherItem: MediaGalleryViewModel.Item = other as? MediaGalleryViewModel.Item else {
|
|
return false
|
|
}
|
|
|
|
return (self == otherItem)
|
|
}
|
|
}
|
|
|
|
extension MediaPageViewController: GalleryRailViewDelegate {
|
|
func galleryRailView(_ galleryRailView: GalleryRailView, didTapItem imageRailItem: GalleryRailItem) {
|
|
guard let targetItem = imageRailItem as? MediaGalleryViewModel.Item else {
|
|
owsFailDebug("unexpected imageRailItem: \(imageRailItem)")
|
|
return
|
|
}
|
|
|
|
self.setCurrentItem(
|
|
targetItem,
|
|
direction: (currentItem.attachmentAlbumIndex < targetItem.attachmentAlbumIndex ?
|
|
.forward :
|
|
.reverse
|
|
),
|
|
animated: true
|
|
)
|
|
}
|
|
}
|
|
|
|
extension MediaPageViewController: CaptionContainerViewDelegate {
|
|
|
|
func captionContainerViewDidUpdateText(_ captionContainerView: CaptionContainerView) {
|
|
updateCaptionContainerVisibility()
|
|
}
|
|
|
|
// MARK: Helpers
|
|
|
|
func updateCaptionContainerVisibility() {
|
|
if let currentText = captionContainerView.currentText, currentText.count > 0 {
|
|
captionContainerView.isHidden = false
|
|
return
|
|
}
|
|
|
|
if let pendingText = captionContainerView.pendingText, pendingText.count > 0 {
|
|
captionContainerView.isHidden = false
|
|
return
|
|
}
|
|
|
|
captionContainerView.isHidden = true
|
|
}
|
|
}
|
|
|
|
// MARK: - UIViewControllerTransitioningDelegate
|
|
|
|
extension MediaPageViewController: UIViewControllerTransitioningDelegate {
|
|
public func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
|
|
guard self == presented || self.navigationController == presented else { return nil }
|
|
|
|
return MediaZoomAnimationController(galleryItem: currentItem)
|
|
}
|
|
|
|
public func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
|
|
guard self == dismissed || self.navigationController == dismissed else { return nil }
|
|
guard !self.viewModel.albumData.isEmpty else { return nil }
|
|
|
|
let animationController = MediaDismissAnimationController(galleryItem: currentItem, interactionController: mediaInteractiveDismiss)
|
|
mediaInteractiveDismiss?.interactiveDismissDelegate = animationController
|
|
|
|
return animationController
|
|
}
|
|
|
|
public func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
|
|
guard let animator = animator as? MediaDismissAnimationController,
|
|
let interactionController = animator.interactionController,
|
|
interactionController.interactionInProgress
|
|
else {
|
|
return nil
|
|
}
|
|
|
|
return interactionController
|
|
}
|
|
}
|
|
|
|
// MARK: - MediaPresentationContextProvider
|
|
|
|
extension MediaPageViewController: MediaPresentationContextProvider {
|
|
func mediaPresentationContext(mediaItem: Media, in coordinateSpace: UICoordinateSpace) -> MediaPresentationContext? {
|
|
let mediaView = currentViewController.mediaView
|
|
|
|
guard let mediaSuperview: UIView = mediaView.superview else { return nil }
|
|
|
|
let presentationFrame = coordinateSpace.convert(mediaView.frame, from: mediaSuperview)
|
|
|
|
return MediaPresentationContext(
|
|
mediaView: mediaView,
|
|
presentationFrame: presentationFrame,
|
|
cornerRadius: 0,
|
|
cornerMask: CACornerMask()
|
|
)
|
|
}
|
|
|
|
func snapshotOverlayView(in coordinateSpace: UICoordinateSpace) -> (UIView, CGRect)? {
|
|
return self.navigationController?.navigationBar.generateSnapshot(in: coordinateSpace)
|
|
}
|
|
}
|