// // Copyright (c) 2019 Open Whisper Systems. All rights reserved. // import Foundation public enum GalleryDirection { case before, after, around } class MediaGalleryAlbum { private var originalItems: [MediaGalleryItem] var items: [MediaGalleryItem] { get { guard let mediaGalleryDataSource = self.mediaGalleryDataSource else { owsFailDebug("mediaGalleryDataSource was unexpectedly nil") return originalItems } return originalItems.filter { !mediaGalleryDataSource.deletedGalleryItems.contains($0) } } } weak var mediaGalleryDataSource: MediaGalleryDataSource? init(items: [MediaGalleryItem]) { self.originalItems = items } func add(item: MediaGalleryItem) { guard !originalItems.contains(item) else { return } originalItems.append(item) originalItems.sort { (lhs, rhs) -> Bool in return lhs.albumIndex < rhs.albumIndex } } } public class MediaGalleryItem: Equatable, Hashable { let message: TSMessage let attachmentStream: TSAttachmentStream let galleryDate: GalleryDate let captionForDisplay: String? let albumIndex: Int var album: MediaGalleryAlbum? let orderingKey: MediaGalleryItemOrderingKey init(message: TSMessage, attachmentStream: TSAttachmentStream) { self.message = message self.attachmentStream = attachmentStream self.captionForDisplay = attachmentStream.caption?.filterForDisplay self.galleryDate = GalleryDate(message: message) self.albumIndex = message.attachmentIds.index(of: attachmentStream.uniqueId!) self.orderingKey = MediaGalleryItemOrderingKey(messageSortKey: message.sortId, attachmentSortKey: albumIndex) } var isVideo: Bool { return attachmentStream.isVideo } var isAnimated: Bool { return attachmentStream.isAnimated } var isImage: Bool { return attachmentStream.isImage } var imageSize: CGSize { return attachmentStream.imageSize() } public typealias AsyncThumbnailBlock = (UIImage) -> Void func thumbnailImage(async:@escaping AsyncThumbnailBlock) -> UIImage? { return attachmentStream.thumbnailImageSmall(success: async, failure: {}) } // MARK: Equatable public static func == (lhs: MediaGalleryItem, rhs: MediaGalleryItem) -> Bool { return lhs.attachmentStream.uniqueId == rhs.attachmentStream.uniqueId } // MARK: Hashable public var hashValue: Int { return attachmentStream.uniqueId?.hashValue ?? attachmentStream.hashValue } // MARK: Sorting struct MediaGalleryItemOrderingKey: Comparable { let messageSortKey: UInt64 let attachmentSortKey: Int // MARK: Comparable static func < (lhs: MediaGalleryItem.MediaGalleryItemOrderingKey, rhs: MediaGalleryItem.MediaGalleryItemOrderingKey) -> Bool { if lhs.messageSortKey < rhs.messageSortKey { return true } if lhs.messageSortKey == rhs.messageSortKey { if lhs.attachmentSortKey < rhs.attachmentSortKey { return true } } return false } } } public struct GalleryDate: Hashable, Comparable, Equatable { let year: Int let month: Int init(message: TSMessage) { let date = message.receivedAtDate() self.year = Calendar.current.component(.year, from: date) self.month = Calendar.current.component(.month, from: date) } init(year: Int, month: Int) { assert(month >= 1 && month <= 12) self.year = year self.month = month } private var isThisMonth: Bool { let now = Date() let year = Calendar.current.component(.year, from: now) let month = Calendar.current.component(.month, from: now) let thisMonth = GalleryDate(year: year, month: month) return self == thisMonth } public var date: Date { var components = DateComponents() components.month = self.month components.year = self.year return Calendar.current.date(from: components)! } private var isThisYear: Bool { let now = Date() let thisYear = Calendar.current.component(.year, from: now) return self.year == thisYear } static let thisYearFormatter: DateFormatter = { let formatter = DateFormatter() formatter.dateFormat = "MMMM" return formatter }() static let olderFormatter: DateFormatter = { let formatter = DateFormatter() // FIXME localize for RTL, or is there a built in way to do this? formatter.dateFormat = "MMMM yyyy" return formatter }() var localizedString: String { if isThisMonth { return NSLocalizedString("MEDIA_GALLERY_THIS_MONTH_HEADER", comment: "Section header in media gallery collection view") } else if isThisYear { return type(of: self).thisYearFormatter.string(from: self.date) } else { return type(of: self).olderFormatter.string(from: self.date) } } // MARK: Hashable public var hashValue: Int { return month.hashValue ^ year.hashValue } // MARK: Comparable public static func < (lhs: GalleryDate, rhs: GalleryDate) -> Bool { if lhs.year != rhs.year { return lhs.year < rhs.year } else if lhs.month != rhs.month { return lhs.month < rhs.month } else { return false } } // MARK: Equatable public static func == (lhs: GalleryDate, rhs: GalleryDate) -> Bool { return lhs.month == rhs.month && lhs.year == rhs.year } } protocol MediaGalleryDataSource: class { var hasFetchedOldest: Bool { get } var hasFetchedMostRecent: Bool { get } var galleryItems: [MediaGalleryItem] { get } var galleryItemCount: Int { get } var sections: [GalleryDate: [MediaGalleryItem]] { get } var sectionDates: [GalleryDate] { get } var deletedAttachments: Set { get } var deletedGalleryItems: Set { get } func ensureGalleryItemsLoaded(_ direction: GalleryDirection, item: MediaGalleryItem, amount: UInt, completion: ((IndexSet, [IndexPath]) -> Void)?) func galleryItem(before currentItem: MediaGalleryItem) -> MediaGalleryItem? func galleryItem(after currentItem: MediaGalleryItem) -> MediaGalleryItem? func showAllMedia(focusedItem: MediaGalleryItem) func dismissMediaDetailViewController(_ mediaDetailViewController: MediaPageViewController, animated isAnimated: Bool, completion: (() -> Void)?) func delete(items: [MediaGalleryItem], initiatedBy: AnyObject) } protocol MediaGalleryDataSourceDelegate: class { func mediaGalleryDataSource(_ mediaGalleryDataSource: MediaGalleryDataSource, willDelete items: [MediaGalleryItem], initiatedBy: AnyObject) func mediaGalleryDataSource(_ mediaGalleryDataSource: MediaGalleryDataSource, deletedSections: IndexSet, deletedItems: [IndexPath]) } class MediaGalleryNavigationController: OWSNavigationController { var presentationView: UIImageView! var retainUntilDismissed: MediaGallery? // HACK: Though we don't have an input accessory view, the VC we are presented above (ConversationVC) does. // If the app is backgrounded and then foregrounded, when OWSWindowManager calls mainWindow.makeKeyAndVisible // the ConversationVC's inputAccessoryView will appear *above* us unless we'd previously become first responder. override public var canBecomeFirstResponder: Bool { Logger.debug("") return true } override public func becomeFirstResponder() -> Bool { Logger.debug("") return super.becomeFirstResponder() } override public func resignFirstResponder() -> Bool { Logger.debug("") return super.resignFirstResponder() } // MARK: View Lifecycle override var preferredStatusBarStyle: UIStatusBarStyle { return .lightContent } override func viewDidLoad() { super.viewDidLoad() // UIModalPresentationCustom retains the current view context behind our VC, allowing us to manually // animate in our view, over the existing context, similar to a cross disolve, but allowing us to have // more fine grained control self.modalPresentationStyle = .custom // The presentationView is only used during present/dismiss animations. // It's a static image of the media content. let presentationView = UIImageView() self.presentationView = presentationView self.view.insertSubview(presentationView, at: 0) presentationView.isHidden = true presentationView.clipsToBounds = true presentationView.layer.allowsEdgeAntialiasing = true presentationView.layer.minificationFilter = CALayerContentsFilter.trilinear presentationView.layer.magnificationFilter = CALayerContentsFilter.trilinear presentationView.contentMode = .scaleAspectFit guard let navigationBar = self.navigationBar as? OWSNavigationBar else { owsFailDebug("navigationBar had unexpected class: \(self.navigationBar)") return } navigationBar.overrideTheme(type: .alwaysDark) } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) // If the user's device is already rotated, try to respect that by rotating to landscape now UIViewController.attemptRotationToDeviceOrientation() } // MARK: Orientation public override var supportedInterfaceOrientations: UIInterfaceOrientationMask { return .allButUpsideDown } } @objc class MediaGallery: NSObject, MediaGalleryDataSource, MediaTileViewControllerDelegate { @objc weak public var navigationController: MediaGalleryNavigationController! var deletedAttachments: Set = Set() var deletedGalleryItems: Set = Set() private var pageViewController: MediaPageViewController? private var uiDatabaseConnection: YapDatabaseConnection { return OWSPrimaryStorage.shared().uiDatabaseConnection } private let editingDatabaseConnection: YapDatabaseConnection private let mediaGalleryFinder: OWSMediaGalleryFinder private var initialDetailItem: MediaGalleryItem? private let thread: TSThread private let options: MediaGalleryOption // we start with a small range size for quick loading. private let fetchRangeSize: UInt = 10 deinit { Logger.debug("") } @objc init(thread: TSThread, options: MediaGalleryOption = []) { self.thread = thread self.editingDatabaseConnection = OWSPrimaryStorage.shared().newDatabaseConnection() self.options = options self.mediaGalleryFinder = OWSMediaGalleryFinder(thread: thread) super.init() NotificationCenter.default.addObserver(self, selector: #selector(uiDatabaseDidUpdate), name: .OWSUIDatabaseConnectionDidUpdate, object: OWSPrimaryStorage.shared().dbNotificationObject) } // MARK: Present/Dismiss private var currentItem: MediaGalleryItem { return self.pageViewController!.currentItem } private var replacingView: UIView? private var presentationViewConstraints: [NSLayoutConstraint] = [] // TODO rename to replacingOriginRect private var originRect: CGRect? @objc public func presentDetailView(fromViewController: UIViewController, mediaAttachment: TSAttachment, replacingView: UIView) { var galleryItem: MediaGalleryItem? uiDatabaseConnection.read { transaction in galleryItem = self.buildGalleryItem(attachment: mediaAttachment, transaction: transaction) } guard let initialDetailItem = galleryItem else { owsFailDebug("unexpectedly failed to build initialDetailItem.") return } presentDetailView(fromViewController: fromViewController, initialDetailItem: initialDetailItem, replacingView: replacingView) } public func presentDetailView(fromViewController: UIViewController, initialDetailItem: MediaGalleryItem, replacingView: UIView) { // For a speedy load, we only fetch a few items on either side of // the initial message ensureGalleryItemsLoaded(.around, item: initialDetailItem, amount: 10) // We lazily load media into the gallery, but with large albums, we want to be sure // we load all the media required to render the album's media rail. ensureAlbumEntirelyLoaded(galleryItem: initialDetailItem) self.initialDetailItem = initialDetailItem let pageViewController = MediaPageViewController(initialItem: initialDetailItem, mediaGalleryDataSource: self, uiDatabaseConnection: self.uiDatabaseConnection, options: self.options) self.addDataSourceDelegate(pageViewController) self.pageViewController = pageViewController let navController = MediaGalleryNavigationController() self.navigationController = navController navController.retainUntilDismissed = self navigationController.setViewControllers([pageViewController], animated: false) self.replacingView = replacingView let convertedRect: CGRect = replacingView.convert(replacingView.bounds, to: UIApplication.shared.keyWindow) self.originRect = convertedRect // loadView hasn't necessarily been called yet. navigationController.loadViewIfNeeded() navigationController.presentationView.image = initialDetailItem.attachmentStream.thumbnailImageLarge(success: { [weak self] (image) in guard let strongSelf = self else { return } strongSelf.navigationController.presentationView.image = image }, failure: { Logger.warn("Could not load presentation image.") }) self.applyInitialMediaViewConstraints() // Restore presentationView.alpha in case a previous dismiss left us in a bad state. navigationController.setNavigationBarHidden(false, animated: false) navigationController.presentationView.alpha = 1 // We want to animate the tapped media from it's position in the previous VC // to it's resting place in the center of this view controller. // // Rather than animating the actual media view in place, we animate the presentationView, which is a static // image of the media content. Animating the actual media view is problematic for a couple reasons: // 1. The media view ultimately lives in a zoomable scrollView. Getting both original positioning and the final positioning // correct, involves manipulating the zoomScale and position simultaneously, which results in non-linear movement, // especially noticeable on high resolution images. // 2. For Video views, the AVPlayerLayer content does not scale with the presentation animation. So you instead get a full scale // video, wherein only the cropping is animated. // Using a simple image view allows us to address both these problems relatively easily. navigationController.view.alpha = 0.0 guard let detailView = pageViewController.view else { owsFailDebug("detailView was unexpectedly nil") return } // At this point our media view should be overlayed perfectly // by our presentationView. Swapping them out should be imperceptible. navigationController.presentationView.isHidden = false // We don't hide the pageViewController entirely - e.g. we want the toolbars to fade in. pageViewController.currentViewController.view.isHidden = true detailView.backgroundColor = .clear navigationController.view.backgroundColor = .clear navigationController.presentationView.layer.cornerRadius = kOWSMessageCellCornerRadius_Small fromViewController.present(navigationController, animated: false) { // 1. Fade in the entire view. UIView.animate(withDuration: 0.1) { self.replacingView?.alpha = 0.0 self.navigationController.view.alpha = 1.0 } self.navigationController.presentationView.superview?.layoutIfNeeded() self.applyFinalMediaViewConstraints() // 2. Animate imageView from it's initial position, which should match where it was // in the presenting view to it's final position, front and center in this view. This // animation duration intentionally overlaps the previous UIView.animate(withDuration: 0.2, delay: 0.08, options: .curveEaseOut, animations: { self.navigationController.presentationView.layer.cornerRadius = 0 self.navigationController.presentationView.superview?.layoutIfNeeded() // fade out content behind the pageViewController // and behind the presentation view self.navigationController.view.backgroundColor = Theme.darkThemeBackgroundColor }, completion: { (_: Bool) in // At this point our presentation view should be overlayed perfectly // with our media view. Swapping them out should be imperceptible. pageViewController.currentViewController.view.isHidden = false self.navigationController.presentationView.isHidden = true self.navigationController.view.isUserInteractionEnabled = true pageViewController.wasPresented() // Since we're presenting *over* the ConversationVC, we need to `becomeFirstResponder`. // // Otherwise, the `ConversationVC.inputAccessoryView` will appear over top of us whenever // OWSWindowManager window juggling calls `[rootWindow makeKeyAndVisible]`. // // We don't need to do this when pushing VCs onto the SignalsNavigationController - only when // presenting directly from ConversationVC. _ = self.navigationController.becomeFirstResponder() }) } } // If we're using a navigationController other than self to present the views // e.g. the conversation settings view controller var fromNavController: OWSNavigationController? @objc func pushTileView(fromNavController: OWSNavigationController) { var mostRecentItem: MediaGalleryItem? self.uiDatabaseConnection.read { transaction in if let attachment = self.mediaGalleryFinder.mostRecentMediaAttachment(transaction: transaction) { mostRecentItem = self.buildGalleryItem(attachment: attachment, transaction: transaction) } } if let mostRecentItem = mostRecentItem { mediaTileViewController.focusedItem = mostRecentItem ensureGalleryItemsLoaded(.around, item: mostRecentItem, amount: 100) } self.fromNavController = fromNavController fromNavController.pushViewController(mediaTileViewController, animated: true) } func showAllMedia(focusedItem: MediaGalleryItem) { // TODO fancy animation - zoom media item into it's tile in the all media grid ensureGalleryItemsLoaded(.around, item: focusedItem, amount: 100) if let fromNavController = self.fromNavController { // If from conversation settings view, we've already pushed fromNavController.popViewController(animated: true) } else { // If from conversation view mediaTileViewController.focusedItem = focusedItem navigationController.pushViewController(mediaTileViewController, animated: true) } } // MARK: MediaTileViewControllerDelegate func mediaTileViewController(_ viewController: MediaTileViewController, didTapView tappedView: UIView, mediaGalleryItem: MediaGalleryItem) { if self.fromNavController != nil { // If we got to the gallery via conversation settings, present the detail view // on top of the tile view // // == ViewController Schematic == // // [DetailView] <--, // [TileView] -----' // [ConversationSettingsView] // [ConversationView] // self.presentDetailView(fromViewController: mediaTileViewController, initialDetailItem: mediaGalleryItem, replacingView: tappedView) } else { // If we got to the gallery via the conversation view, pop the tile view // to return to the detail view // // == ViewController Schematic == // // [TileView] -----, // [DetailView] <--' // [ConversationView] // guard let pageViewController = self.pageViewController else { owsFailDebug("pageViewController was unexpectedly nil") self.navigationController.dismiss(animated: true) return } pageViewController.setCurrentItem(mediaGalleryItem, direction: .forward, animated: false) pageViewController.willBePresentedAgain() // TODO fancy zoom animation self.navigationController.popViewController(animated: true) } } public func dismissMediaDetailViewController(_ mediaPageViewController: MediaPageViewController, animated isAnimated: Bool, completion completionParam: (() -> Void)?) { guard let presentingViewController = self.navigationController.presentingViewController else { owsFailDebug("presentingController was unexpectedly nil") return } let completion = { completionParam?() UIApplication.shared.isStatusBarHidden = false presentingViewController.setNeedsStatusBarAppearanceUpdate() } navigationController.view.isUserInteractionEnabled = false guard let detailView = mediaPageViewController.view else { owsFailDebug("detailView was unexpectedly nil") self.navigationController.presentingViewController?.dismiss(animated: false, completion: completion) return } mediaPageViewController.currentViewController.view.isHidden = true navigationController.presentationView.isHidden = false // Move the presentationView back to it's initial position, i.e. where // it sits on the screen in the conversation view. let changedItems = currentItem != self.initialDetailItem if changedItems { navigationController.presentationView.image = currentItem.attachmentStream.thumbnailImageLarge(success: { [weak self] (image) in guard let strongSelf = self else { return } strongSelf.navigationController.presentationView.image = image }, failure: { Logger.warn("Could not load presentation image.") }) self.applyOffscreenMediaViewConstraints() } else { self.applyInitialMediaViewConstraints() } if isAnimated { UIView.animate(withDuration: changedItems ? 0.25 : 0.18, delay: 0.0, options: .curveEaseOut, animations: { // Move back over it's original location self.navigationController.presentationView.superview?.layoutIfNeeded() detailView.alpha = 0 if changedItems { self.navigationController.presentationView.alpha = 0 } else { self.navigationController.presentationView.layer.cornerRadius = kOWSMessageCellCornerRadius_Small } }) // This intentionally overlaps the previous animation a bit UIView.animate(withDuration: 0.1, delay: 0.15, options: .curveEaseInOut, animations: { guard let replacingView = self.replacingView else { owsFailDebug("replacingView was unexpectedly nil") presentingViewController.dismiss(animated: false, completion: completion) return } // fade out content and toolbars self.navigationController.view.alpha = 0.0 replacingView.alpha = 1.0 }, completion: { (_: Bool) in presentingViewController.dismiss(animated: false, completion: completion) }) } else { guard let replacingView = self.replacingView else { owsFailDebug("replacingView was unexpectedly nil") presentingViewController.dismiss(animated: false, completion: completion) return } replacingView.alpha = 1.0 presentingViewController.dismiss(animated: false, completion: completion) } } private func applyInitialMediaViewConstraints() { if (self.presentationViewConstraints.count > 0) { NSLayoutConstraint.deactivate(self.presentationViewConstraints) self.presentationViewConstraints = [] } guard let originRect = self.originRect else { owsFailDebug("originRect was unexpectedly nil") return } guard let presentationSuperview = navigationController.presentationView.superview else { owsFailDebug("presentationView.superview was unexpectedly nil") return } let convertedRect: CGRect = presentationSuperview.convert(originRect, from: UIApplication.shared.keyWindow) self.presentationViewConstraints += navigationController.presentationView.autoSetDimensions(to: convertedRect.size) self.presentationViewConstraints += [ navigationController.presentationView.autoPinEdge(toSuperviewEdge: .top, withInset: convertedRect.origin.y), navigationController.presentationView.autoPinEdge(toSuperviewEdge: .left, withInset: convertedRect.origin.x) ] } private func applyFinalMediaViewConstraints() { if (self.presentationViewConstraints.count > 0) { NSLayoutConstraint.deactivate(self.presentationViewConstraints) self.presentationViewConstraints = [] } self.presentationViewConstraints = [ navigationController.presentationView.autoPinEdge(toSuperviewEdge: .leading), navigationController.presentationView.autoPinEdge(toSuperviewEdge: .top), navigationController.presentationView.autoPinEdge(toSuperviewEdge: .trailing), navigationController.presentationView.autoPinEdge(toSuperviewEdge: .bottom) ] } private func applyOffscreenMediaViewConstraints() { if (self.presentationViewConstraints.count > 0) { NSLayoutConstraint.deactivate(self.presentationViewConstraints) self.presentationViewConstraints = [] } self.presentationViewConstraints += [ navigationController.presentationView.autoPinEdge(toSuperviewEdge: .leading), navigationController.presentationView.autoPinEdge(toSuperviewEdge: .trailing), navigationController.presentationView.autoPinEdge(.top, to: .bottom, of: self.navigationController.view) ] } // MARK: - Database Notifications @objc func uiDatabaseDidUpdate(notification: Notification) { guard let notifications = notification.userInfo?[OWSUIDatabaseConnectionNotificationsKey] as? [Notification] else { owsFailDebug("notifications was unexpectedly nil") return } guard mediaGalleryFinder.hasMediaChanges(in: notifications, dbConnection: uiDatabaseConnection) else { Logger.verbose("no changes for thread: \(thread)") return } let rowChanges = extractRowChanges(notifications: notifications) assert(rowChanges.count > 0) process(rowChanges: rowChanges) } func extractRowChanges(notifications: [Notification]) -> [YapDatabaseViewRowChange] { return notifications.flatMap { notification -> [YapDatabaseViewRowChange] in guard let userInfo = notification.userInfo else { owsFailDebug("userInfo was unexpectedly nil") return [] } guard let extensionChanges = userInfo["extensions"] as? [AnyHashable: Any] else { owsFailDebug("extensionChanges was unexpectedly nil") return [] } guard let galleryData = extensionChanges[OWSMediaGalleryFinder.databaseExtensionName()] as? [AnyHashable: Any] else { owsFailDebug("galleryData was unexpectedly nil") return [] } guard let galleryChanges = galleryData["changes"] as? [Any] else { owsFailDebug("gallerlyChanges was unexpectedly nil") return [] } return galleryChanges.compactMap { $0 as? YapDatabaseViewRowChange } } } func process(rowChanges: [YapDatabaseViewRowChange]) { let deleteChanges = rowChanges.filter { $0.type == .delete } let deletedItems: [MediaGalleryItem] = deleteChanges.compactMap { (deleteChange: YapDatabaseViewRowChange) -> MediaGalleryItem? in guard let deletedItem = self.galleryItems.first(where: { galleryItem in galleryItem.attachmentStream.uniqueId == deleteChange.collectionKey.key }) else { Logger.debug("deletedItem was never loaded - no need to remove.") return nil } return deletedItem } self.delete(items: deletedItems, initiatedBy: self) } // MARK: - MediaGalleryDataSource lazy var mediaTileViewController: MediaTileViewController = { let vc = MediaTileViewController(mediaGalleryDataSource: self, uiDatabaseConnection: self.uiDatabaseConnection) vc.delegate = self self.addDataSourceDelegate(vc) return vc }() var galleryItems: [MediaGalleryItem] = [] var sections: [GalleryDate: [MediaGalleryItem]] = [:] var sectionDates: [GalleryDate] = [] var hasFetchedOldest = false var hasFetchedMostRecent = false func buildGalleryItem(attachment: TSAttachment, transaction: YapDatabaseReadTransaction) -> MediaGalleryItem? { guard let attachmentStream = attachment as? TSAttachmentStream else { owsFailDebug("gallery doesn't yet support showing undownloaded attachments") return nil } guard let message = attachmentStream.fetchAlbumMessage(with: transaction) else { owsFailDebug("message was unexpectedly nil") return nil } let galleryItem = MediaGalleryItem(message: message, attachmentStream: attachmentStream) galleryItem.album = getAlbum(item: galleryItem) return galleryItem } func ensureAlbumEntirelyLoaded(galleryItem: MediaGalleryItem) { ensureGalleryItemsLoaded(.before, item: galleryItem, amount: UInt(galleryItem.albumIndex)) let followingCount = galleryItem.message.attachmentIds.count - 1 - galleryItem.albumIndex guard followingCount >= 0 else { return } ensureGalleryItemsLoaded(.after, item: galleryItem, amount: UInt(followingCount)) } var galleryAlbums: [String: MediaGalleryAlbum] = [:] func getAlbum(item: MediaGalleryItem) -> MediaGalleryAlbum? { guard let albumMessageId = item.attachmentStream.albumMessageId else { return nil } guard let existingAlbum = galleryAlbums[albumMessageId] else { let newAlbum = MediaGalleryAlbum(items: [item]) galleryAlbums[albumMessageId] = newAlbum newAlbum.mediaGalleryDataSource = self return newAlbum } existingAlbum.add(item: item) return existingAlbum } // Range instead of indexSet since it's contiguous? var fetchedIndexSet = IndexSet() { didSet { Logger.debug("\(oldValue) -> \(fetchedIndexSet)") } } enum MediaGalleryError: Error { case itemNoLongerExists } func ensureGalleryItemsLoaded(_ direction: GalleryDirection, item: MediaGalleryItem, amount: UInt, completion: ((IndexSet, [IndexPath]) -> Void)? = nil ) { var galleryItems: [MediaGalleryItem] = self.galleryItems var sections: [GalleryDate: [MediaGalleryItem]] = self.sections var sectionDates: [GalleryDate] = self.sectionDates var newGalleryItems: [MediaGalleryItem] = [] var newDates: [GalleryDate] = [] do { try Bench(title: "fetching gallery items") { try self.uiDatabaseConnection.read { transaction in guard let index = self.mediaGalleryFinder.mediaIndex(attachment: item.attachmentStream, transaction: transaction) else { throw MediaGalleryError.itemNoLongerExists } let initialIndex: Int = index.intValue let mediaCount: Int = Int(self.mediaGalleryFinder.mediaCount(transaction: transaction)) let requestRange: Range = { () -> Range in let range: Range = { () -> Range in switch direction { case .around: // To keep it simple, this isn't exactly *amount* sized if `message` window overlaps the end or // beginning of the view. Still, we have sufficient buffer to fetch more as the user swipes. let start: Int = initialIndex - Int(amount) / 2 let end: Int = initialIndex + Int(amount) / 2 return start.. (requestSet.count / 2) // ...but we always fulfill even small requests if we're getting just the tail end of a gallery. let isFetchingEdgeOfGallery = (self.fetchedIndexSet.count - unfetchedSet.count) < requestSet.count guard isSubstantialRequest || isFetchingEdgeOfGallery else { Logger.debug("ignoring small fetch request: \(unfetchedSet.count)") return } Logger.debug("fetching set: \(unfetchedSet)") let nsRange: NSRange = NSRange(location: unfetchedSet.min()!, length: unfetchedSet.count) self.mediaGalleryFinder.enumerateMediaAttachments(range: nsRange, transaction: transaction) { (attachment: TSAttachment) in guard !self.deletedAttachments.contains(attachment) else { Logger.debug("skipping \(attachment) which has been deleted.") return } guard let item: MediaGalleryItem = self.buildGalleryItem(attachment: attachment, transaction: transaction) else { owsFailDebug("unexpectedly failed to buildGalleryItem") return } let date = item.galleryDate galleryItems.append(item) if sections[date] != nil { sections[date]!.append(item) // so we can update collectionView newGalleryItems.append(item) } else { sectionDates.append(date) sections[date] = [item] // so we can update collectionView newDates.append(date) newGalleryItems.append(item) } } self.fetchedIndexSet = self.fetchedIndexSet.union(unfetchedSet) self.hasFetchedOldest = self.fetchedIndexSet.min() == 0 self.hasFetchedMostRecent = self.fetchedIndexSet.max() == mediaCount - 1 } } } catch MediaGalleryError.itemNoLongerExists { Logger.debug("Ignoring reload, since item no longer exists.") return } catch { owsFailDebug("unexpected error: \(error)") return } // TODO only sort if changed var sortedSections: [GalleryDate: [MediaGalleryItem]] = [:] Bench(title: "sorting gallery items") { galleryItems.sort { lhs, rhs -> Bool in return lhs.orderingKey < rhs.orderingKey } sectionDates.sort() for (date, galleryItems) in sections { sortedSections[date] = galleryItems.sorted { lhs, rhs -> Bool in return lhs.orderingKey < rhs.orderingKey } } } self.galleryItems = galleryItems self.sections = sortedSections self.sectionDates = sectionDates if let completionBlock = completion { Bench(title: "calculating changes for collectionView") { // FIXME can we avoid this index offset? let dateIndices = newDates.map { sectionDates.firstIndex(of: $0)! + 1 } let addedSections: IndexSet = IndexSet(dateIndices) let addedItems: [IndexPath] = newGalleryItems.map { galleryItem in let sectionIdx = sectionDates.firstIndex(of: galleryItem.galleryDate)! let section = sections[galleryItem.galleryDate]! let itemIdx = section.firstIndex(of: galleryItem)! // FIXME can we avoid this index offset? return IndexPath(item: itemIdx, section: sectionIdx + 1) } completionBlock(addedSections, addedItems) } } } var dataSourceDelegates: [Weak] = [] func addDataSourceDelegate(_ dataSourceDelegate: MediaGalleryDataSourceDelegate) { dataSourceDelegates.append(Weak(value: dataSourceDelegate)) } func delete(items: [MediaGalleryItem], initiatedBy: AnyObject) { AssertIsOnMainThread() Logger.info("with items: \(items.map { ($0.attachmentStream, $0.message.timestamp) })") deletedGalleryItems.formUnion(items) dataSourceDelegates.forEach { $0.value?.mediaGalleryDataSource(self, willDelete: items, initiatedBy: initiatedBy) } for item in items { self.deletedAttachments.insert(item.attachmentStream) } self.editingDatabaseConnection.asyncReadWrite { transaction in for item in items { let message = item.message let attachment = item.attachmentStream message.removeAttachment(attachment, transaction: transaction) if message.attachmentIds.count == 0 { Logger.debug("removing message after removing last media attachment") message.remove(with: transaction) } } } var deletedSections: IndexSet = IndexSet() var deletedIndexPaths: [IndexPath] = [] let originalSections = self.sections let originalSectionDates = self.sectionDates for item in items { guard let itemIndex = galleryItems.firstIndex(of: item) else { owsFailDebug("removing unknown item.") return } self.galleryItems.remove(at: itemIndex) guard let sectionIndex = sectionDates.firstIndex(where: { $0 == item.galleryDate }) else { owsFailDebug("item with unknown date.") return } guard var sectionItems = self.sections[item.galleryDate] else { owsFailDebug("item with unknown section") return } guard let sectionRowIndex = sectionItems.firstIndex(of: item) else { owsFailDebug("item with unknown sectionRowIndex") return } // We need to calculate the index of the deleted item with respect to it's original position. guard let originalSectionIndex = originalSectionDates.firstIndex(where: { $0 == item.galleryDate }) else { owsFailDebug("item with unknown date.") return } guard let originalSectionItems = originalSections[item.galleryDate] else { owsFailDebug("item with unknown section") return } guard let originalSectionRowIndex = originalSectionItems.firstIndex(of: item) else { owsFailDebug("item with unknown sectionRowIndex") return } if sectionItems == [item] { // Last item in section. Delete section. self.sections[item.galleryDate] = nil self.sectionDates.remove(at: sectionIndex) deletedSections.insert(originalSectionIndex + 1) deletedIndexPaths.append(IndexPath(row: originalSectionRowIndex, section: originalSectionIndex + 1)) } else { sectionItems.remove(at: sectionRowIndex) self.sections[item.galleryDate] = sectionItems deletedIndexPaths.append(IndexPath(row: originalSectionRowIndex, section: originalSectionIndex + 1)) } } dataSourceDelegates.forEach { $0.value?.mediaGalleryDataSource(self, deletedSections: deletedSections, deletedItems: deletedIndexPaths) } } let kGallerySwipeLoadBatchSize: UInt = 5 internal func galleryItem(after currentItem: MediaGalleryItem) -> MediaGalleryItem? { Logger.debug("") self.ensureGalleryItemsLoaded(.after, item: currentItem, amount: kGallerySwipeLoadBatchSize) guard let currentIndex = galleryItems.firstIndex(of: currentItem) else { owsFailDebug("currentIndex was unexpectedly nil") return nil } let index: Int = galleryItems.index(after: currentIndex) guard let nextItem = galleryItems[safe: index] else { // already at last item return nil } guard !deletedGalleryItems.contains(nextItem) else { Logger.debug("nextItem was deleted - Recursing.") return galleryItem(after: nextItem) } return nextItem } internal func galleryItem(before currentItem: MediaGalleryItem) -> MediaGalleryItem? { Logger.debug("") self.ensureGalleryItemsLoaded(.before, item: currentItem, amount: kGallerySwipeLoadBatchSize) guard let currentIndex = galleryItems.firstIndex(of: currentItem) else { owsFailDebug("currentIndex was unexpectedly nil") return nil } let index: Int = galleryItems.index(before: currentIndex) guard let previousItem = galleryItems[safe: index] else { // already at first item return nil } guard !deletedGalleryItems.contains(previousItem) else { Logger.debug("previousItem was deleted - Recursing.") return galleryItem(before: previousItem) } return previousItem } var galleryItemCount: Int { var count: UInt = 0 self.uiDatabaseConnection.read { (transaction: YapDatabaseReadTransaction) in count = self.mediaGalleryFinder.mediaCount(transaction: transaction) } return Int(count) - deletedAttachments.count } }