From 3058cb8733f0fd24db1481345b880a3cf1575eb2 Mon Sep 17 00:00:00 2001 From: Michael Kirk Date: Fri, 23 Mar 2018 10:00:07 -0400 Subject: [PATCH] Batch Delete // FREEBIE --- .../BlueCheckSelected_31x31_@1x.png | Bin 0 -> 1392 bytes .../BlueCheckSelected_31x31_@2x.png | Bin 0 -> 2657 bytes .../BlueCheckSelected_31x31_@3x.png | Bin 0 -> 5270 bytes .../Contents.json | 23 + .../MediaGalleryViewController.swift | 92 ++-- .../MediaPageViewController.swift | 11 +- .../MediaTileViewController.swift | 399 ++++++++++++++---- .../MessageDetailViewController.swift | 5 +- .../translations/en.lproj/Localizable.strings | 9 + 9 files changed, 420 insertions(+), 119 deletions(-) create mode 100644 Signal/Images.xcassets/selected_blue_circle.imageset/BlueCheckSelected_31x31_@1x.png create mode 100644 Signal/Images.xcassets/selected_blue_circle.imageset/BlueCheckSelected_31x31_@2x.png create mode 100644 Signal/Images.xcassets/selected_blue_circle.imageset/BlueCheckSelected_31x31_@3x.png create mode 100644 Signal/Images.xcassets/selected_blue_circle.imageset/Contents.json 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 0000000000000000000000000000000000000000..58d24dfe4474e755d4b3e4a1d471c0308f258029 GIT binary patch literal 1392 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 P00000NkvXXu0mjf;AT2F literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..e4d9736ddaef0225f3eb1750146079d3111c0cc8 GIT binary patch literal 5270 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 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..4014f3d37 100644 --- a/Signal/src/ViewControllers/MediaPageViewController.swift +++ b/Signal/src/ViewControllers/MediaPageViewController.swift @@ -327,7 +327,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 +502,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..8c161f230 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,32 @@ 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: footerBarHeight) + footerBar.layoutMargins = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 40) + // footerBar is offscreen until user hits "select" + self.footerBarBottomConstraint = footerBar.autoPinEdge(toSuperviewEdge: .bottom, withInset: -footerBarHeight) + + updateSelectButton() + self.view.layoutIfNeeded() scrollToBottom(animated: false) } @@ -123,7 +145,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 +159,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 +355,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 +365,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 +419,160 @@ 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 + // show toolbar + UIView.animate(withDuration: 0.1, delay: 0, options: .curveEaseInOut, animations: { + self.footerBarBottomConstraint.constant = 0 + self.footerBar.superview?.layoutIfNeeded() + }, 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 + // hide toolbar + UIView.animate(withDuration: 0.1, delay: 0, options: .curveEaseInOut, animations: { + self.footerBarBottomConstraint.constant = self.footerBarHeight + self.footerBar.superview?.layoutIfNeeded() + }, completion: nil) + + self.navigationItem.hidesBackButton = false + + // deselect any selected + guard let collectionView = self.collectionView else { + owsFail("\(logTag) in \(#function) collectionView was unexpectedly nil") + return + } + + 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! + var footerBarHeight: CGFloat { + // bottomLayoutGuide accomodates iPhoneX + return 40 + self.bottomLayoutGuide.length + } + + // 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 +802,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 +837,69 @@ 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 var item: MediaGalleryItem? - public weak var delegate: MediaGalleryCellDelegate? + private let highlightView: UIView + 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.alpha = self.isSelected ? 0.8 : 1.0 + } + } + + override var isHighlighted: Bool { + didSet { + self.highlightView.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.highlightView = UIView() + highlightView.alpha = 0.2 + highlightView.backgroundColor = .black + highlightView.isHidden = true + + super.init(frame: frame) self.clipsToBounds = true + self.contentView.addSubview(imageView) - self.contentView.addSubview(badgeView) + self.contentView.addSubview(contentTypeBadgeView) + self.contentView.addSubview(selectedBadgeView) + self.contentView.addSubview(highlightView) imageView.autoPinEdgesToSuperviewEdges() + highlightView.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 +907,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 +927,7 @@ 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.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";