diff --git a/Signal/Images.xcassets/selected_blue_circle.imageset/BlueCheckSelected_31x31_@1x.png b/Signal/Images.xcassets/selected_blue_circle.imageset/BlueCheckSelected_31x31_@1x.png new file mode 100644 index 000000000..58d24dfe4 Binary files /dev/null and b/Signal/Images.xcassets/selected_blue_circle.imageset/BlueCheckSelected_31x31_@1x.png differ diff --git a/Signal/Images.xcassets/selected_blue_circle.imageset/BlueCheckSelected_31x31_@2x.png b/Signal/Images.xcassets/selected_blue_circle.imageset/BlueCheckSelected_31x31_@2x.png new file mode 100644 index 000000000..381a5f76d Binary files /dev/null and b/Signal/Images.xcassets/selected_blue_circle.imageset/BlueCheckSelected_31x31_@2x.png differ diff --git a/Signal/Images.xcassets/selected_blue_circle.imageset/BlueCheckSelected_31x31_@3x.png b/Signal/Images.xcassets/selected_blue_circle.imageset/BlueCheckSelected_31x31_@3x.png new file mode 100644 index 000000000..e4d9736dd Binary files /dev/null and b/Signal/Images.xcassets/selected_blue_circle.imageset/BlueCheckSelected_31x31_@3x.png differ diff --git a/Signal/Images.xcassets/selected_blue_circle.imageset/Contents.json b/Signal/Images.xcassets/selected_blue_circle.imageset/Contents.json new file mode 100644 index 000000000..e067f4bef --- /dev/null +++ b/Signal/Images.xcassets/selected_blue_circle.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "BlueCheckSelected_31x31_@1x.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "BlueCheckSelected_31x31_@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "BlueCheckSelected_31x31_@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Signal/src/ViewControllers/MediaGalleryViewController.swift b/Signal/src/ViewControllers/MediaGalleryViewController.swift index 9f1d5c2d7..02e72d5d1 100644 --- a/Signal/src/ViewControllers/MediaGalleryViewController.swift +++ b/Signal/src/ViewControllers/MediaGalleryViewController.swift @@ -176,11 +176,11 @@ protocol MediaGalleryDataSource: class { func showAllMedia(focusedItem: MediaGalleryItem) func dismissMediaDetailViewController(_ mediaDetailViewController: MediaPageViewController, animated isAnimated: Bool, completion: (() -> Void)?) - func delete(message: TSMessage) + func delete(items: [MediaGalleryItem]) } protocol MediaGalleryDataSourceDelegate: class { - func mediaGalleryDataSource(_ mediaGalleryDataSource: MediaGalleryDataSource, willDelete message: TSMessage) + func mediaGalleryDataSource(_ mediaGalleryDataSource: MediaGalleryDataSource, willDelete items: [MediaGalleryItem]) func mediaGalleryDataSource(_ mediaGalleryDataSource: MediaGalleryDataSource, deletedSections: IndexSet, deletedItems: [IndexPath]) } @@ -725,58 +725,82 @@ class MediaGalleryViewController: UINavigationController, MediaGalleryDataSource weak var dataSourceDelegate: MediaGalleryDataSourceDelegate? var deletedMessages: Set = Set() - func delete(message: TSMessage) { - Logger.info("\(logTag) in \(#function) with message: \(String(describing: message.uniqueId)) attachmentId: \(String(describing: message.attachmentIds.firstObject))") - self.dataSourceDelegate?.mediaGalleryDataSource(self, willDelete: message) + func delete(items: [MediaGalleryItem]) { + AssertIsOnMainThread() + + Logger.info("\(logTag) in \(#function) with items: \(items)") + + dataSourceDelegate?.mediaGalleryDataSource(self, willDelete: items) self.editingDatabaseConnection.asyncReadWrite { transaction in - message.remove(with: transaction) + for item in items { + let message = item.message + message.remove(with: transaction) + self.deletedMessages.insert(message) + } } - self.deletedMessages.insert(message) - var deletedSections: IndexSet = IndexSet() var deletedIndexPaths: [IndexPath] = [] + let originalSections = self.sections + let originalSectionDates = self.sectionDates - guard let itemIndex = galleryItems.index(where: { $0.message == message }) else { - owsFail("\(logTag) in \(#function) removing unknown item.") - return - } - let item: MediaGalleryItem = galleryItems[itemIndex] - - self.galleryItems.remove(at: itemIndex) + for item in items { + guard let itemIndex = galleryItems.index(of: item) else { + owsFail("\(logTag) in \(#function) removing unknown item.") + return + } - guard let sectionIndex = sectionDates.index(where: { $0 == item.galleryDate }) else { - owsFail("\(logTag) in \(#function) item with unknown date.") - return - } + self.galleryItems.remove(at: itemIndex) - guard var sectionItems = self.sections[item.galleryDate] else { - owsFail("\(logTag) in \(#function) item with unknown section") - return - } + guard let sectionIndex = sectionDates.index(where: { $0 == item.galleryDate }) else { + owsFail("\(logTag) in \(#function) item with unknown date.") + return + } - if sectionItems == [item] { - // Last item in section. Delete section. - self.sections[item.galleryDate] = nil - self.sectionDates.remove(at: sectionIndex) + guard var sectionItems = self.sections[item.galleryDate] else { + owsFail("\(logTag) in \(#function) item with unknown section") + return + } - deletedSections.insert(sectionIndex + 1) - deletedIndexPaths.append(IndexPath(row: 0, section: sectionIndex + 1)) - } else { guard let sectionRowIndex = sectionItems.index(of: item) else { owsFail("\(logTag) in \(#function) item with unknown sectionRowIndex") return } - sectionItems.remove(at: sectionRowIndex) - self.sections[item.galleryDate] = sectionItems + // We need to calculate the index of the deleted item with respect to it's original position. + guard let originalSectionIndex = originalSectionDates.index(where: { $0 == item.galleryDate }) else { + owsFail("\(logTag) in \(#function) item with unknown date.") + return + } + + guard let originalSectionItems = originalSections[item.galleryDate] else { + owsFail("\(logTag) in \(#function) item with unknown section") + return + } - deletedIndexPaths.append(IndexPath(row: sectionRowIndex, section: sectionIndex + 1)) + guard let originalSectionRowIndex = originalSectionItems.index(of: item) else { + owsFail("\(logTag) in \(#function) 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)) + } } - self.dataSourceDelegate?.mediaGalleryDataSource(self, deletedSections: deletedSections, deletedItems: deletedIndexPaths) + dataSourceDelegate?.mediaGalleryDataSource(self, deletedSections: deletedSections, deletedItems: deletedIndexPaths) } let kGallerySwipeLoadBatchSize: UInt = 5 diff --git a/Signal/src/ViewControllers/MediaPageViewController.swift b/Signal/src/ViewControllers/MediaPageViewController.swift index 5dfe651d7..dc99b9c01 100644 --- a/Signal/src/ViewControllers/MediaPageViewController.swift +++ b/Signal/src/ViewControllers/MediaPageViewController.swift @@ -59,6 +59,7 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou self.updateTitle(item: item) self.setViewControllers([galleryPage], direction: direction, animated: isAnimated) + self.updateFooterBarButtonItems(isPlayingVideo: false) } private let uiDatabaseConnection: YapDatabaseConnection @@ -327,7 +328,7 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou // else we deleted the last piece of media, return to the conversation view self.dismissSelf(animated: true) } - mediaGalleryDataSource.delete(message: deletedItem.message) + mediaGalleryDataSource.delete(items: [deletedItem]) } actionSheet.addAction(OWSAlerts.cancelAction) actionSheet.addAction(deleteAction) @@ -502,8 +503,15 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou return } + guard let galleryItem = self.mediaGalleryDataSource?.galleryItems.first(where: { $0.message == message }) else { + owsFail("\(logTag) in \(#function) unexpected interaction: \(type(of: conversationViewItem))") + self.presentingViewController?.dismiss(animated: true) + + return + } + dismissSelf(animated: true) { - mediaGalleryDataSource.delete(message: message) + mediaGalleryDataSource.delete(items: [galleryItem]) } } diff --git a/Signal/src/ViewControllers/MediaTileViewController.swift b/Signal/src/ViewControllers/MediaTileViewController.swift index 0a0ec40d7..b3462da81 100644 --- a/Signal/src/ViewControllers/MediaTileViewController.swift +++ b/Signal/src/ViewControllers/MediaTileViewController.swift @@ -8,7 +8,7 @@ public protocol MediaTileViewControllerDelegate: class { func mediaTileViewController(_ viewController: MediaTileViewController, didTapView tappedView: UIView, mediaGalleryItem: MediaGalleryItem) } -public class MediaTileViewController: UICollectionViewController, MediaGalleryCellDelegate, MediaGalleryDataSourceDelegate { +public class MediaTileViewController: UICollectionViewController, MediaGalleryDataSourceDelegate { private weak var mediaGalleryDataSource: MediaGalleryDataSource? @@ -88,10 +88,30 @@ public class MediaTileViewController: UICollectionViewController, MediaGalleryCe collectionView.delegate = self - // TODO iPhoneX // feels a bit weird to have content smashed all the way to the bottom edge. collectionView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 20, right: 0) + let footerBar = UIToolbar() + self.footerBar = footerBar + let deleteButton = UIBarButtonItem(barButtonSystemItem: .trash, + target:self, + action:#selector(didPressDelete)) + self.deleteButton = deleteButton + let footerItems = [ + UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target:nil, action:nil), + deleteButton, + UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target:nil, action:nil), + ] + footerBar.setItems(footerItems, animated: false) + + self.view.addSubview(self.footerBar) + footerBar.barTintColor = UIColor.ows_signalBrandBlue + footerBar.autoPinWidthToSuperview() + footerBar.autoSetDimension(.height, toSize: kFooterBarHeight) + self.footerBarBottomConstraint = footerBar.autoPinEdge(toSuperviewEdge: .bottom, withInset: -kFooterBarHeight) + + updateSelectButton() + self.view.layoutIfNeeded() scrollToBottom(animated: false) } @@ -123,7 +143,7 @@ public class MediaTileViewController: UICollectionViewController, MediaGalleryCe self.collectionView?.scrollToItem(at: indexPath, at: .centeredVertically, animated: false) } - // MARK: UIColletionViewDelegate + // MARK: UICollectionViewDelegate override public func scrollViewDidScroll(_ scrollView: UIScrollView) { self.autoLoadMoreIfNecessary() @@ -137,42 +157,87 @@ public class MediaTileViewController: UICollectionViewController, MediaGalleryCe self.isUserScrolling = false } - private var isUserScrolling: Bool = false { - didSet { - autoLoadMoreIfNecessary() + override public func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool { + + Logger.debug("\(self.logTag) in \(#function)") + + guard galleryDates.count > 0 else { + return false + } + + switch indexPath.section { + case kLoadOlderSectionIdx, loadNewerSectionIdx: + return false + default: + return true } } - // MARK: MediaGalleryDataSourceDelegate + override public func collectionView(_ collectionView: UICollectionView, shouldDeselectItemAt indexPath: IndexPath) -> Bool { - func mediaGalleryDataSource(_ mediaGalleryDataSource: MediaGalleryDataSource, willDelete message: TSMessage) { - guard let collectionView = self.collectionView else { - owsFail("\(logTag) in \(#function) collectionView was unexpectedly nil") - return + Logger.debug("\(self.logTag) in \(#function)") + + guard galleryDates.count > 0 else { + return false } - // We've got to lay out the collectionView before any changes are made to the date source - // otherwise we'll fail when we try to remove the deleted sections/rows - collectionView.layoutIfNeeded() + switch indexPath.section { + case kLoadOlderSectionIdx, loadNewerSectionIdx: + return false + default: + return true + } } - func mediaGalleryDataSource(_ mediaGalleryDataSource: MediaGalleryDataSource, deletedSections: IndexSet, deletedItems: [IndexPath]) { - guard let collectionView = self.collectionView else { - owsFail("\(logTag) in \(#function) collectionView was unexpetedly nil") + public override func collectionView(_ collectionView: UICollectionView, shouldHighlightItemAt indexPath: IndexPath) -> Bool { + + Logger.debug("\(self.logTag) in \(#function)") + + guard galleryDates.count > 0 else { + return false + } + + switch indexPath.section { + case kLoadOlderSectionIdx, loadNewerSectionIdx: + return false + default: + return true + } + } + + override public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + Logger.debug("\(self.logTag) in \(#function)") + + guard let galleryCell = self.collectionView(collectionView, cellForItemAt: indexPath) as? MediaGalleryCell else { + owsFail("\(logTag) in \(#function) galleryCell was unexpectedly nil") return } - guard mediaGalleryDataSource.galleryItemCount > 0 else { - // Show Empty - self.collectionView?.reloadData() + guard let galleryItem = galleryCell.item else { + owsFail("\(logTag) in \(#function) galleryItem was unexpectedly nil") return } - // If collectionView hasn't been laid out yet, it won't have the sections/rows to remove. - collectionView.performBatchUpdates({ - collectionView.deleteSections(deletedSections) - collectionView.deleteItems(at: deletedItems) - }) + if isInBatchSelectMode { + updateDeleteButton() + } else { + collectionView.deselectItem(at: indexPath, animated: true) + self.delegate?.mediaTileViewController(self, didTapView: galleryCell.imageView, mediaGalleryItem: galleryItem) + } + } + + public override func collectionView(_ collectionView: UICollectionView, didDeselectItemAt indexPath: IndexPath) { + Logger.debug("\(self.logTag) in \(#function)") + + if isInBatchSelectMode { + updateDeleteButton() + } + } + + private var isUserScrolling: Bool = false { + didSet { + autoLoadMoreIfNecessary() + } } // MARK: UICollectionViewDataSource @@ -288,18 +353,8 @@ public class MediaTileViewController: UICollectionViewController, MediaGalleryCe owsFail("\(logTag) in \(#function) unexpected cell for loadNewerSectionIdx") return defaultCell default: - guard let sectionDate = self.galleryDates[safe: indexPath.section - 1] else { - owsFail("\(logTag) in \(#function) unknown section: \(indexPath.section)") - return defaultCell - } - - guard let sectionItems = self.galleryItems[sectionDate] else { - owsFail("\(logTag) in \(#function) no section for date: \(sectionDate)") - return defaultCell - } - - guard let galleryItem = sectionItems[safe: indexPath.row] else { - owsFail("\(logTag) in \(#function) no message for row: \(indexPath.row)") + guard let galleryItem = galleryItem(at: indexPath) else { + owsFail("\(logTag) in \(#function) no message for path: \(indexPath)") return defaultCell } @@ -308,12 +363,31 @@ public class MediaTileViewController: UICollectionViewController, MediaGalleryCe return defaultCell } - cell.configure(item: galleryItem, delegate: self) + cell.configure(item: galleryItem) return cell } } + func galleryItem(at indexPath: IndexPath) -> MediaGalleryItem? { + guard let sectionDate = self.galleryDates[safe: indexPath.section - 1] else { + owsFail("\(logTag) in \(#function) unknown section: \(indexPath.section)") + return nil + } + + guard let sectionItems = self.galleryItems[sectionDate] else { + owsFail("\(logTag) in \(#function) no section for date: \(sectionDate)") + return nil + } + + guard let galleryItem = sectionItems[safe: indexPath.row] else { + owsFail("\(logTag) in \(#function) no message for row: \(indexPath.row)") + return nil + } + + return galleryItem + } + // MARK: UICollectionViewDelegateFlowLayout public func collectionView(_ collectionView: UICollectionView, @@ -343,11 +417,173 @@ public class MediaTileViewController: UICollectionViewController, MediaGalleryCe return kMonthHeaderSize } } - // MARK: MediaGalleryDelegate - fileprivate func didTapCell(_ cell: MediaGalleryCell, item: MediaGalleryItem) { - Logger.debug("\(logTag) in \(#function)") - self.delegate?.mediaTileViewController(self, didTapView: cell.imageView, mediaGalleryItem: item) + // MARK: Batch Selection + + var isInBatchSelectMode = false { + didSet { + collectionView!.allowsMultipleSelection = isInBatchSelectMode + updateSelectButton() + updateDeleteButton() + } + } + + func updateDeleteButton() { + guard let collectionView = self.collectionView else { + owsFail("\(logTag) in \(#function) collectionView was unexpectedly nil") + return + } + + if let count = collectionView.indexPathsForSelectedItems?.count, count > 0 { + self.deleteButton.isEnabled = true + } else { + self.deleteButton.isEnabled = false + } + } + + func updateSelectButton() { + if isInBatchSelectMode { + self.navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(didCancelSelect)) + } else { + self.navigationItem.rightBarButtonItem = UIBarButtonItem(title: NSLocalizedString("BUTTON_SELECT", comment: "Button text to enable batch selection mode"), + style: .plain, + target: self, + action: #selector(didTapSelect)) + } + } + + @objc + func didTapSelect(_ sender: Any) { + isInBatchSelectMode = true + + guard let collectionView = self.collectionView else { + owsFail("\(logTag) in \(#function) collectionView was unexpectedly nil") + return + } + + // show toolbar + UIView.animate(withDuration: 0.1, delay: 0, options: .curveEaseInOut, animations: { + NSLayoutConstraint.deactivate([self.footerBarBottomConstraint]) + self.footerBarBottomConstraint = self.footerBar.autoPin(toBottomLayoutGuideOf: self, withInset: 0) + + self.footerBar.superview?.layoutIfNeeded() + + // ensure toolbar doesn't cover bottom row. + collectionView.contentInset.bottom += self.kFooterBarHeight + }, completion: nil) + + // disabled until at least one item is selected + self.deleteButton.isEnabled = false + + // Don't allow the user to leave mid-selection, so they realized they have + // to cancel (lose) their selection if they leave. + self.navigationItem.hidesBackButton = true + } + + @objc + func didCancelSelect(_ sender: Any) { + isInBatchSelectMode = false + + guard let collectionView = self.collectionView else { + owsFail("\(logTag) in \(#function) collectionView was unexpectedly nil") + return + } + + // hide toolbar + UIView.animate(withDuration: 0.1, delay: 0, options: .curveEaseInOut, animations: { + NSLayoutConstraint.deactivate([self.footerBarBottomConstraint]) + self.footerBarBottomConstraint = self.footerBar.autoPinEdge(toSuperviewEdge: .bottom, withInset: -self.kFooterBarHeight) + self.footerBar.superview?.layoutIfNeeded() + + // undo "ensure toolbar doesn't cover bottom row." + collectionView.contentInset.bottom -= self.kFooterBarHeight + }, completion: nil) + + self.navigationItem.hidesBackButton = false + + // deselect any selected + collectionView.indexPathsForSelectedItems?.forEach { collectionView.deselectItem(at: $0, animated: false)} + } + + @objc + func didPressDelete(_ sender: Any) { + Logger.debug("\(self.logTag) in \(#function)") + + guard let collectionView = self.collectionView else { + owsFail("\(logTag) in \(#function) collectionView was unexpectedly nil") + return + } + + guard let indexPaths = collectionView.indexPathsForSelectedItems else { + owsFail("\(logTag) in \(#function) indexPaths was unexpectedly nil") + return + } + + let items: [MediaGalleryItem] = indexPaths.flatMap { return self.galleryItem(at: $0) } + + guard let mediaGalleryDataSource = self.mediaGalleryDataSource else { + owsFail("\(logTag) in \(#function) mediaGalleryDataSource was unexpectedly nil") + return + } + + let confirmationTitle: String = { + if indexPaths.count == 1 { + return NSLocalizedString("MEDIA_GALLERY_DELETE_SINGLE_MESSAGE", comment: "Confirmation button text to delete selected media message from the gallery") + } else { + let format = NSLocalizedString("MEDIA_GALLERY_DELETE_MULTIPLE_MESSAGES_FORMAT", comment: "Confirmation button text to delete selected media from the gallery, embeds {{number of messages}}") + return String(format: format, indexPaths.count) + } + }() + + let deleteAction = UIAlertAction(title: confirmationTitle, style: .destructive) { _ in + mediaGalleryDataSource.delete(items: items) + } + + let actionSheet = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) + actionSheet.addAction(deleteAction) + actionSheet.addAction(OWSAlerts.cancelAction) + + present(actionSheet, animated: true) + } + + var footerBar: UIToolbar! + var deleteButton: UIBarButtonItem! + var footerBarBottomConstraint: NSLayoutConstraint! + let kFooterBarHeight: CGFloat = 40 + + // MARK: MediaGalleryDataSourceDelegate + + func mediaGalleryDataSource(_ mediaGalleryDataSource: MediaGalleryDataSource, willDelete items: [MediaGalleryItem]) { + Logger.debug("\(self.logTag) in \(#function)") + + guard let collectionView = self.collectionView else { + owsFail("\(logTag) in \(#function) collectionView was unexpectedly nil") + return + } + + // We've got to lay out the collectionView before any changes are made to the date source + // otherwise we'll fail when we try to remove the deleted sections/rows + collectionView.layoutIfNeeded() + } + + func mediaGalleryDataSource(_ mediaGalleryDataSource: MediaGalleryDataSource, deletedSections: IndexSet, deletedItems: [IndexPath]) { + Logger.debug("\(self.logTag) in \(#function) with deletedSections: \(deletedSections) deletedItems: \(deletedItems)") + + guard let collectionView = self.collectionView else { + owsFail("\(logTag) in \(#function) collectionView was unexpetedly nil") + return + } + + guard mediaGalleryDataSource.galleryItemCount > 0 else { + // Show Empty + self.collectionView?.reloadData() + return + } + + collectionView.performBatchUpdates({ + collectionView.deleteSections(deletedSections) + collectionView.deleteItems(at: deletedItems) + }) } // MARK: Lazy Loading @@ -577,10 +813,6 @@ fileprivate class MediaGallerySectionHeader: UICollectionReusableView { } } -fileprivate protocol MediaGalleryCellDelegate: class { - func didTapCell(_ cell: MediaGalleryCell, item: MediaGalleryItem) -} - fileprivate class MediaGalleryStaticHeader: UICollectionViewCell { static let reuseIdentifier = "MediaGalleryStaticHeader" @@ -616,40 +848,78 @@ fileprivate class MediaGalleryCell: UICollectionViewCell { static let reuseIdentifier = "MediaGalleryCell" public let imageView: UIImageView - private var tapGesture: UITapGestureRecognizer! - private let badgeView: UIImageView + private let contentTypeBadgeView: UIImageView + private let selectedBadgeView: UIImageView + + private let highlightedView: UIView + private let selectedView: UIView - private var item: MediaGalleryItem? - public weak var delegate: MediaGalleryCellDelegate? + fileprivate var item: MediaGalleryItem? static let videoBadgeImage = #imageLiteral(resourceName: "ic_gallery_badge_video") static let animatedBadgeImage = #imageLiteral(resourceName: "ic_gallery_badge_gif") + static let selectedBadgeImage = #imageLiteral(resourceName: "selected_blue_circle") + + override var isSelected: Bool { + didSet { + self.selectedBadgeView.isHidden = !self.isSelected + self.selectedView.isHidden = !self.isSelected + } + } + + override var isHighlighted: Bool { + didSet { + self.highlightedView.isHidden = !self.isHighlighted + } + } override init(frame: CGRect) { self.imageView = UIImageView() imageView.contentMode = .scaleAspectFill - self.badgeView = UIImageView() - badgeView.isHidden = true + self.contentTypeBadgeView = UIImageView() + contentTypeBadgeView.isHidden = true - super.init(frame: frame) + self.selectedBadgeView = UIImageView() + selectedBadgeView.image = MediaGalleryCell.selectedBadgeImage + selectedBadgeView.isHidden = true - self.tapGesture = UITapGestureRecognizer(target: self, action: #selector(didTap)) - self.addGestureRecognizer(tapGesture) + self.highlightedView = UIView() + highlightedView.alpha = 0.2 + highlightedView.backgroundColor = .black + highlightedView.isHidden = true + + self.selectedView = UIView() + selectedView.alpha = 0.3 + selectedView.backgroundColor = .white + selectedView.isHidden = true + + super.init(frame: frame) self.clipsToBounds = true + self.contentView.addSubview(imageView) - self.contentView.addSubview(badgeView) + self.contentView.addSubview(contentTypeBadgeView) + self.contentView.addSubview(highlightedView) + self.contentView.addSubview(selectedView) + self.contentView.addSubview(selectedBadgeView) imageView.autoPinEdgesToSuperviewEdges() + highlightedView.autoPinEdgesToSuperviewEdges() + selectedView.autoPinEdgesToSuperviewEdges() // Note assets were rendered to match exactly. We don't want to re-size with // content mode lest they become less legible. - let kBadgeSize = CGSize(width: 18, height: 12) - badgeView.autoPinEdge(toSuperviewEdge: .leading, withInset: 3) - badgeView.autoPinEdge(toSuperviewEdge: .bottom, withInset: 3) - badgeView.autoSetDimensions(to: kBadgeSize) + let kContentTypeBadgeSize = CGSize(width: 18, height: 12) + contentTypeBadgeView.autoPinEdge(toSuperviewEdge: .leading, withInset: 3) + contentTypeBadgeView.autoPinEdge(toSuperviewEdge: .bottom, withInset: 3) + contentTypeBadgeView.autoSetDimensions(to: kContentTypeBadgeSize) + + let kSelectedBadgeSize = CGSize(width: 31, height: 31) + selectedBadgeView.autoPinEdge(toSuperviewEdge: .trailing, withInset: 0) + selectedBadgeView.autoPinEdge(toSuperviewEdge: .bottom, withInset: 0) + selectedBadgeView.autoSetDimensions(to: kSelectedBadgeSize) } @available(*, unavailable, message: "Unimplemented") @@ -657,21 +927,19 @@ fileprivate class MediaGalleryCell: UICollectionViewCell { fatalError("init(coder:) has not been implemented") } - public func configure(item: MediaGalleryItem, delegate: MediaGalleryCellDelegate) { + public func configure(item: MediaGalleryItem) { self.item = item self.imageView.image = item.thumbnailImage if item.isVideo { - self.badgeView.isHidden = false - self.badgeView.image = MediaGalleryCell.videoBadgeImage + self.contentTypeBadgeView.isHidden = false + self.contentTypeBadgeView.image = MediaGalleryCell.videoBadgeImage } else if item.isAnimated { - self.badgeView.isHidden = false - self.badgeView.image = MediaGalleryCell.animatedBadgeImage + self.contentTypeBadgeView.isHidden = false + self.contentTypeBadgeView.image = MediaGalleryCell.animatedBadgeImage } else { assert(item.isImage) - self.badgeView.isHidden = true + self.contentTypeBadgeView.isHidden = true } - - self.delegate = delegate } override public func prepareForReuse() { @@ -679,18 +947,9 @@ fileprivate class MediaGalleryCell: UICollectionViewCell { self.item = nil self.imageView.image = nil - self.badgeView.isHidden = true - self.delegate = nil - } - - // MARK: Events - - func didTap(gestureRecognizer: UITapGestureRecognizer) { - guard let item = self.item else { - owsFail("\(logTag) item was unexpectedly nil") - return - } - - self.delegate?.didTapCell(self, item: item) + self.contentTypeBadgeView.isHidden = true + self.highlightedView.isHidden = true + self.selectedView.isHidden = true + self.selectedBadgeView.isHidden = true } } diff --git a/Signal/src/ViewControllers/MessageDetailViewController.swift b/Signal/src/ViewControllers/MessageDetailViewController.swift index 0a14b5b4d..6eb86c153 100644 --- a/Signal/src/ViewControllers/MessageDetailViewController.swift +++ b/Signal/src/ViewControllers/MessageDetailViewController.swift @@ -758,9 +758,10 @@ class MessageDetailViewController: OWSViewController, UIScrollViewDelegate, Medi // MediaGalleryDataSourceDelegate - func mediaGalleryDataSource(_ mediaGalleryDataSource: MediaGalleryDataSource, willDelete message: TSMessage) { + func mediaGalleryDataSource(_ mediaGalleryDataSource: MediaGalleryDataSource, willDelete items: [MediaGalleryItem]) { Logger.info("\(self.logTag) in \(#function)") - guard message == self.message else { + + guard (items.map({ $0.message }) == [self.message]) else { // Should only be one message we can delete when viewing message details owsFail("\(logTag) in \(#function) Unexpectedly informed of irrelevant message deletion") return diff --git a/Signal/translations/en.lproj/Localizable.strings b/Signal/translations/en.lproj/Localizable.strings index 7e28c1985..2d1d0225d 100644 --- a/Signal/translations/en.lproj/Localizable.strings +++ b/Signal/translations/en.lproj/Localizable.strings @@ -262,6 +262,9 @@ /* Label for generic done button. */ "BUTTON_DONE" = "Done"; +/* Button text to enable batch selection mode */ +"BUTTON_SELECT" = "Select"; + /* Alert message when calling and permissions for microphone are missing */ "CALL_AUDIO_PERMISSION_MESSAGE" = "Signal requires access to your microphone to make calls and record voice messages. You can grant this permission in the Settings app."; @@ -983,6 +986,12 @@ /* media picker option to choose from library */ "MEDIA_FROM_LIBRARY_BUTTON" = "Photo Library"; +/* Confirmation button text to delete selected media from the gallery, embeds {{number of messages}} */ +"MEDIA_GALLERY_DELETE_MULTIPLE_MESSAGES_FORMAT" = "Delete %d Messages"; + +/* Confirmation button text to delete selected media message from the gallery */ +"MEDIA_GALLERY_DELETE_SINGLE_MESSAGE" = "Delete Message"; + /* Short sender label for media sent by you */ "MEDIA_GALLERY_SENDER_NAME_YOU" = "You";