diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index c744acd2d9..dbdc5d1e87 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 06289300DC49EDEA6FEC730C /* Pods_SignalPerformanceTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C61A9604F0FC0D258C8CE27F /* Pods_SignalPerformanceTests.framework */; }; + 1477630B275E20D700D1067E /* ThreadSwipeHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1477630A275E20D700D1067E /* ThreadSwipeHandler.swift */; }; 1704690A25D4C326000793D8 /* SignalAttachmentTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1704690925D4C2E6000793D8 /* SignalAttachmentTest.swift */; }; 1704690C25D4C92B000793D8 /* test-jpg-rotated.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 1704690B25D4C92B000793D8 /* test-jpg-rotated.jpg */; }; 173878BE256341BB00AD39C7 /* SessionMigrationPerfTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 173878BD256341BB00AD39C7 /* SessionMigrationPerfTest.swift */; }; @@ -1154,6 +1155,7 @@ 02E4AF4827F1AD3630C8B77B /* Pods-SignalUITests.profiling.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SignalUITests.profiling.xcconfig"; path = "Pods/Target Support Files/Pods-SignalUITests/Pods-SignalUITests.profiling.xcconfig"; sourceTree = ""; }; 0435CB909E03469591D260F3 /* Pods-SignalUI.testable release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SignalUI.testable release.xcconfig"; path = "Pods/Target Support Files/Pods-SignalUI/Pods-SignalUI.testable release.xcconfig"; sourceTree = ""; }; 10AE4264D3E52937D8964A86 /* Pods-SignalMessaging.profiling.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SignalMessaging.profiling.xcconfig"; path = "Pods/Target Support Files/Pods-SignalMessaging/Pods-SignalMessaging.profiling.xcconfig"; sourceTree = ""; }; + 1477630A275E20D700D1067E /* ThreadSwipeHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadSwipeHandler.swift; sourceTree = ""; }; 14FD26DD3B40616C54CDBF1A /* Pods-SignalShareExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SignalShareExtension.debug.xcconfig"; path = "Pods/Target Support Files/Pods-SignalShareExtension/Pods-SignalShareExtension.debug.xcconfig"; sourceTree = ""; }; 1704690925D4C2E6000793D8 /* SignalAttachmentTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignalAttachmentTest.swift; sourceTree = ""; }; 1704690B25D4C92B000793D8 /* test-jpg-rotated.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "test-jpg-rotated.jpg"; sourceTree = ""; }; @@ -2706,6 +2708,7 @@ 34E95C1D269F4F4F004807EC /* HVRenderState.swift */, 34E95C20269F4F4F004807EC /* HVTableDataSource.swift */, 34E95C1F269F4F4F004807EC /* HVViewState.swift */, + 1477630A275E20D700D1067E /* ThreadSwipeHandler.swift */, ); path = HomeView; sourceTree = ""; @@ -6325,6 +6328,7 @@ 887B380A25F0427F00685845 /* NotificationSettingsViewController.swift in Sources */, 3426A37025631C250036407F /* CVComponentLinkPreview.swift in Sources */, 45DDA6242090CEB500DE97F8 /* ConversationHeaderView.swift in Sources */, + 1477630B275E20D700D1067E /* ThreadSwipeHandler.swift in Sources */, 881BB28A2743531D00B609B8 /* BadgeDetailsSheet.swift in Sources */, 887B380825F0355700685845 /* ChatsSettingsViewController.swift in Sources */, 8847E6F226A0EFBD0063E319 /* AvatarEditViewController.swift in Sources */, diff --git a/Signal/Signal-Info.plist b/Signal/Signal-Info.plist index 99750a940e..47068c40a2 100644 --- a/Signal/Signal-Info.plist +++ b/Signal/Signal-Info.plist @@ -43,7 +43,7 @@ CFBundleVersion - 38 + 39 ITSAppUsesNonExemptEncryption LOGS_EMAIL @@ -141,7 +141,7 @@ INStartCallIntent OWSBundleVersion4 - 5.27.0.38 + 5.27.0.39 PHPhotoLibraryPreventAutomaticLimitedAccessAlert UIAppFonts diff --git a/Signal/src/ViewControllers/HomeView/ConversationSearchViewController.swift b/Signal/src/ViewControllers/HomeView/ConversationSearchViewController.swift index e729d670d1..33057d7f7b 100644 --- a/Signal/src/ViewControllers/HomeView/ConversationSearchViewController.swift +++ b/Signal/src/ViewControllers/HomeView/ConversationSearchViewController.swift @@ -11,7 +11,7 @@ public protocol ConversationSearchViewDelegate: AnyObject { } @objc -public class ConversationSearchViewController: UITableViewController { +public class ConversationSearchViewController: UITableViewController, ThreadSwipeHandler { // MARK: - @@ -255,14 +255,16 @@ public class ConversationSearchViewController: UITableViewController { } } - private func cellContentToken(forConfiguration configuration: HomeViewCell.Configuration) -> HVCellContentToken { + private func cellContentToken(forConfiguration configuration: HomeViewCell.Configuration, useCache: Bool = true) -> HVCellContentToken { AssertIsOnMainThread() // If we have an existing HVCellContentToken, use it. // Cell measurement/arrangement is expensive. let cacheKey = "\(configuration.thread.threadRecord.uniqueId).\(configuration.overrideSnippet?.string ?? "")" - if let cellContentToken = cellContentCache.get(key: cacheKey) { - return cellContentToken + if useCache { + if let cellContentToken = cellContentCache.get(key: cacheKey) { + return cellContentToken + } } let cellContentToken = HomeViewCell.buildCellContentToken(forConfiguration: configuration) @@ -472,6 +474,16 @@ public class ConversationSearchViewController: UITableViewController { } } + public override func tableView(_ tableView: UITableView, + trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { + return trailingSwipeActionsConfiguration(for: getThreadViewModelFor(indexPath: indexPath)) + } + + public override func tableView(_ tableView: UITableView, + leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { + return leadingSwipeActionsConfiguration(for: getThreadViewModelFor(indexPath: indexPath)) + } + // MARK: Update Search Results var refreshTimer: Timer? @@ -514,7 +526,14 @@ public class ConversationSearchViewController: UITableViewController { reloadTableData() return } - guard lastSearchText != searchText else { + + // a database change will lead to a search with the searchText=lastSearchText + // in this case we only want to update the visible cells + var updateCellCandidates: [HomeViewCell]? + if lastSearchText == searchText { + updateCellCandidates = tableView.visibleCells.filter {$0 as? HomeViewCell != nil} as? [HomeViewCell] + } + guard updateCellCandidates == nil || updateCellCandidates!.count > 0 else { // Ignoring redundant search. return } @@ -538,14 +557,48 @@ public class ConversationSearchViewController: UITableViewController { // Discard results from stale search. return } - self.searchResultSet = results - self.reloadTableData() + if let candidates = updateCellCandidates { + for cell in candidates { + if let (section, row) = self.getIndexPathFor(threadId: cell.thread?.uniqueId), let configuration = self.cellConfiguration(searchSection: section, row: row) { + cell.reset() + cell.configure(cellContentToken: self.cellContentToken(forConfiguration: configuration, useCache: false)) + } + } + } else { + self.reloadTableData() + } }) } // MARK: - + private func getThreadViewModelFor(indexPath: IndexPath) -> ThreadViewModel? { + if let searchSection = SearchSection(rawValue: indexPath.section) { + if searchSection == .contactThreads { + return searchResultSet.contactThreads[indexPath.row].thread + } else if searchSection == .groupThreads { + return searchResultSet.groupThreads[indexPath.row].thread + } + } + return nil + } + + private func getIndexPathFor(threadId: String?) -> (SearchSection, Int)? { + if let threadId = threadId { + if let row = searchResultSet.contactThreads.map({$0.thread.threadRecord.uniqueId}).firstIndex(of: threadId) { + return (SearchSection.contactThreads, row) + } + if let row = searchResultSet.groupThreads.map({$0.thread.threadRecord.uniqueId}).firstIndex(of: threadId) { + return (SearchSection.groupThreads, row) + } + if let row = searchResultSet.messages.map({$0.thread.threadRecord.uniqueId}).firstIndex(of: threadId) { + return (SearchSection.messages, row) + } + } + return nil + } + private func isBlocked(thread: ThreadViewModel) -> Bool { owsAssertDebug(thread.homeViewInfo != nil) diff --git a/Signal/src/ViewControllers/HomeView/HVTableDataSource.swift b/Signal/src/ViewControllers/HomeView/HVTableDataSource.swift index 9667d0b1a0..b6823ac0bb 100644 --- a/Signal/src/ViewControllers/HomeView/HVTableDataSource.swift +++ b/Signal/src/ViewControllers/HomeView/HVTableDataSource.swift @@ -665,72 +665,15 @@ extension HVTableDataSource: UITableViewDataSource { return nil } - let muteAction = UIContextualAction(style: .normal, - title: nil) { [weak viewController] (_, _, completion) in - if threadViewModel.isMuted { - viewController?.unmuteThread(threadViewModel: threadViewModel) - } else { - viewController?.muteThreadWithSelection(threadViewModel: threadViewModel) + return viewController.trailingSwipeActionsConfiguration(for: threadViewModel, + closeConversationBlock: { [weak self] in + if let self = self, self.isConversationActive(forThread: threadViewModel.threadRecord) { + viewController.conversationSplitViewController?.closeSelectedConversation(animated: true) } - completion(false) - } - muteAction.backgroundColor = .ows_accentIndigo - muteAction.image = self.actionImage(name: threadViewModel.isMuted ? "bell-solid-24" : "bell-disabled-solid-24", - title: threadViewModel.isMuted ? CommonStrings.unmuteButton : CommonStrings.muteButton) - muteAction.accessibilityLabel = threadViewModel.isMuted ? CommonStrings.unmuteButton :CommonStrings.muteButton - - let deleteAction = UIContextualAction(style: .destructive, - title: nil) { [weak viewController] (_, _, completion) in - viewController?.deleteThreadWithConfirmation(threadViewModel: threadViewModel) - completion(false) - } - deleteAction.backgroundColor = .ows_accentRed - deleteAction.image = self.actionImage(name: "trash-solid-24", - title: CommonStrings.deleteButton) - deleteAction.accessibilityLabel = CommonStrings.deleteButton - - let archiveAction = UIContextualAction(style: .normal, - title: nil) { [weak viewController] (_, _, completion) in - viewController?.archiveThread(threadViewModel: threadViewModel) - completion(false) - } - - let archiveTitle = (viewController.homeViewMode == .inbox - ? CommonStrings.archiveAction - : CommonStrings.unarchiveAction) - - archiveAction.backgroundColor = Theme.isDarkThemeEnabled ? .ows_gray45 : .ows_gray25 - archiveAction.image = self.actionImage(name: "archive-solid-24", - title: archiveTitle) - archiveAction.accessibilityLabel = archiveTitle - - // The first action will be auto-performed for "very long swipes". - return UISwipeActionsConfiguration(actions: [ archiveAction, deleteAction, muteAction ]) + }) } } - private func actionImage(name imageName: String, title: String) -> UIImage? { - AssertIsOnMainThread() - - // We need to bake the title text into the image because `UIContextualAction` - // only displays title + image when the cell's height > 91. We want to always - // show both. - guard let image = UIImage(named: imageName) else { - owsFailDebug("Missing image.") - return nil - } - guard let image = image.withTitle(title, - font: UIFont.systemFont(ofSize: 13), - color: .ows_white, - maxTitleWidth: 68, - minimumScaleFactor: CGFloat(8) / CGFloat(13), - spacing: 4) else { - owsFailDebug("Missing image.") - return nil - } - return image.withRenderingMode(.alwaysTemplate) - } - public func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { AssertIsOnMainThread() @@ -768,57 +711,12 @@ extension HVTableDataSource: UITableViewDataSource { owsFailDebug("Missing threadViewModel.") return nil } - let thread = threadViewModel.threadRecord - - let isThreadPinned = PinnedThreadManager.isThreadPinned(thread) - let pinnedStateAction: UIContextualAction - if isThreadPinned { - pinnedStateAction = UIContextualAction(style: .normal, - title: nil) { [weak viewController] (_, _, completion) in - viewController?.unpinThread(threadViewModel: threadViewModel) - completion(false) - } - pinnedStateAction.backgroundColor = UIColor(rgbHex: 0xff990a) - pinnedStateAction.accessibilityLabel = CommonStrings.unpinAction - pinnedStateAction.image = actionImage(name: "unpin-solid-24", - title: CommonStrings.unpinAction) - } else { - pinnedStateAction = UIContextualAction(style: .destructive, - title: nil) { [weak viewController] (_, _, completion) in - completion(false) - viewController?.pinThread(threadViewModel: threadViewModel) - } - pinnedStateAction.backgroundColor = UIColor(rgbHex: 0xff990a) - pinnedStateAction.accessibilityLabel = CommonStrings.pinAction - pinnedStateAction.image = actionImage(name: "pin-solid-24", - title: CommonStrings.pinAction) + guard let viewController = self.viewController else { + owsFailDebug("Missing viewController.") + return nil } - let readStateAction: UIContextualAction - if threadViewModel.hasUnreadMessages { - readStateAction = UIContextualAction(style: .destructive, - title: nil) { [weak viewController] (_, _, completion) in - completion(false) - viewController?.markThreadAsRead(threadViewModel: threadViewModel) - } - readStateAction.backgroundColor = .ows_accentBlue - readStateAction.accessibilityLabel = CommonStrings.readAction - readStateAction.image = actionImage(name: "read-solid-24", - title: CommonStrings.readAction) - } else { - readStateAction = UIContextualAction(style: .normal, - title: nil) { [weak viewController] (_, _, completion) in - completion(false) - viewController?.markThreadAsUnread(threadViewModel: threadViewModel) - } - readStateAction.backgroundColor = .ows_accentBlue - readStateAction.accessibilityLabel = CommonStrings.unreadAction - readStateAction.image = actionImage(name: "unread-solid-24", - title: CommonStrings.unreadAction) - } - - // The first action will be auto-performed for "very long swipes". - return UISwipeActionsConfiguration(actions: [ readStateAction, pinnedStateAction ]) + return viewController.leadingSwipeActionsConfiguration(for: threadViewModel) } } } diff --git a/Signal/src/ViewControllers/HomeView/HomeViewController+Actions.swift b/Signal/src/ViewControllers/HomeView/HomeViewController+Actions.swift index 8df3098c6d..b886091f5b 100644 --- a/Signal/src/ViewControllers/HomeView/HomeViewController+Actions.swift +++ b/Signal/src/ViewControllers/HomeView/HomeViewController+Actions.swift @@ -3,149 +3,9 @@ // import Foundation -import SignalCoreKit extension HomeViewController { - func archiveThread(threadViewModel: ThreadViewModel) { - AssertIsOnMainThread() - - // If this conversation is currently selected, close it. - if isConversationActive(forThread: threadViewModel.threadRecord) { - conversationSplitViewController?.closeSelectedConversation(animated: true) - } - - databaseStorage.write { transaction in - switch self.homeViewMode { - case .inbox: - threadViewModel.associatedData.updateWith(isArchived: true, - updateStorageService: true, - transaction: transaction) - case .archive: - threadViewModel.associatedData.updateWith(isArchived: false, - updateStorageService: true, - transaction: transaction) - } - } - updateViewState() - } - - func deleteThreadWithConfirmation(threadViewModel: ThreadViewModel) { - AssertIsOnMainThread() - - let alert = ActionSheetController(title: NSLocalizedString("CONVERSATION_DELETE_CONFIRMATION_ALERT_TITLE", - comment: "Title for the 'conversation delete confirmation' alert."), - message: NSLocalizedString("CONVERSATION_DELETE_CONFIRMATION_ALERT_MESSAGE", - comment: "Message for the 'conversation delete confirmation' alert.")) - alert.addAction(ActionSheetAction(title: CommonStrings.deleteButton, - style: .destructive) { [weak self] _ in - self?.deleteThread(threadViewModel: threadViewModel) - }) - alert.addAction(OWSActionSheets.cancelAction) - - presentActionSheet(alert) - } - - func deleteThread(threadViewModel: ThreadViewModel) { - AssertIsOnMainThread() - - // If this conversation is currently selected, close it. - if isConversationActive(forThread: threadViewModel.threadRecord) { - conversationSplitViewController?.closeSelectedConversation(animated: true) - } - - databaseStorage.write { transaction in - threadViewModel.threadRecord.softDelete(with: transaction) - } - - // TODO: Rename this method. - updateViewState() - } - - func markThreadAsRead(threadViewModel: ThreadViewModel) { - AssertIsOnMainThread() - - databaseStorage.write { transaction in - threadViewModel.threadRecord.markAllAsRead(updateStorageService: true, transaction: transaction) - } - } - - func markThreadAsUnread(threadViewModel: ThreadViewModel) { - AssertIsOnMainThread() - - databaseStorage.write { transaction in - threadViewModel.associatedData.updateWith(isMarkedUnread: true, updateStorageService: true, transaction: transaction) - } - } - - func muteThreadWithSelection(threadViewModel: ThreadViewModel) { - AssertIsOnMainThread() - - let alert = ActionSheetController(title: NSLocalizedString("CONVERSATION_MUTE_CONFIRMATION_ALERT_TITLE", - comment: "Title for the 'conversation mute confirmation' alert.")) - for (title, seconds) in [ - (NSLocalizedString("CONVERSATION_MUTE_CONFIRMATION_OPTION_1H", comment: "1 hour"), kHourInterval), - (NSLocalizedString("CONVERSATION_MUTE_CONFIRMATION_OPTION_8H", comment: "8 hours"), 8 * kHourInterval), - (NSLocalizedString("CONVERSATION_MUTE_CONFIRMATION_OPTION_1D", comment: "1 day"), kDayInterval), - (NSLocalizedString("CONVERSATION_MUTE_CONFIRMATION_OPTION_1W", comment: "1 week"), kWeekInterval), - (NSLocalizedString("CONVERSATION_MUTE_CONFIRMATION_OPTION_ALWAYS", comment: "Always"), -1)] { - alert.addAction(ActionSheetAction(title: title, style: .default) { [weak self] _ in - self?.muteThread(threadViewModel: threadViewModel, duration: seconds) - }) - } - alert.addAction(OWSActionSheets.cancelAction) - - presentActionSheet(alert) - } - - func muteThread(threadViewModel: ThreadViewModel, duration seconds: TimeInterval) { - AssertIsOnMainThread() - - databaseStorage.write { transaction in - let timeStamp = seconds < 0 - ? ThreadAssociatedData.alwaysMutedTimestamp - : (seconds == 0 ? 0 : Date.ows_millisecondTimestamp() + UInt64(seconds * 1000)) - threadViewModel.associatedData.updateWith(mutedUntilTimestamp: timeStamp, updateStorageService: true, transaction: transaction) - } - } - - func unmuteThread(threadViewModel: ThreadViewModel) { - AssertIsOnMainThread() - - databaseStorage.write { transaction in - threadViewModel.associatedData.updateWith(mutedUntilTimestamp: Date.ows_millisecondTimestamp(), updateStorageService: true, transaction: transaction) - } - } - - func pinThread(threadViewModel: ThreadViewModel) { - AssertIsOnMainThread() - - do { - try databaseStorage.write { transaction in - try PinnedThreadManager.pinThread(threadViewModel.threadRecord, updateStorageService: true, transaction: transaction) - } - } catch { - if case PinnedThreadError.tooManyPinnedThreads = error { - OWSActionSheets.showActionSheet(title: NSLocalizedString("PINNED_CONVERSATION_LIMIT", - comment: "An explanation that you have already pinned the maximum number of conversations.")) - } else { - owsFailDebug("Error: \(error)") - } - } - } - - func unpinThread(threadViewModel: ThreadViewModel) { - AssertIsOnMainThread() - - do { - try databaseStorage.write { transaction in - try PinnedThreadManager.unpinThread(threadViewModel.threadRecord, updateStorageService: true, transaction: transaction) - } - } catch { - owsFailDebug("Error: \(error)") - } - } - func selectPreviousConversation() { AssertIsOnMainThread() diff --git a/Signal/src/ViewControllers/HomeView/HomeViewController.swift b/Signal/src/ViewControllers/HomeView/HomeViewController.swift index 6e0e6018fb..9a57b52ac6 100644 --- a/Signal/src/ViewControllers/HomeView/HomeViewController.swift +++ b/Signal/src/ViewControllers/HomeView/HomeViewController.swift @@ -405,3 +405,9 @@ extension HomeViewController: BadgeExpirationSheetDelegate { func badgeExpirationSheetNotNowButtonTapped(_ badgeExpirationSheet: BadgeExpirationSheet) { } } + +extension HomeViewController: ThreadSwipeHandler { + func updateUIAfterSwipeAction() { + updateViewState() + } +} diff --git a/Signal/src/ViewControllers/HomeView/ThreadSwipeHandler.swift b/Signal/src/ViewControllers/HomeView/ThreadSwipeHandler.swift new file mode 100644 index 0000000000..0301ab6aaf --- /dev/null +++ b/Signal/src/ViewControllers/HomeView/ThreadSwipeHandler.swift @@ -0,0 +1,252 @@ +// +// Copyright (c) 2021 Open Whisper Systems. All rights reserved. +// + +import Foundation +import SignalMessaging +import UIKit + +@objc +protocol ThreadSwipeHandler { + @objc optional func updateUIAfterSwipeAction() +} + +extension ThreadSwipeHandler where Self: UIViewController { + + func leadingSwipeActionsConfiguration(for threadViewModel: ThreadViewModel?) -> UISwipeActionsConfiguration? { + AssertIsOnMainThread() + + guard let threadViewModel = threadViewModel else { + return nil + } + + let thread = threadViewModel.threadRecord + let isThreadPinned = PinnedThreadManager.isThreadPinned(thread) + let pinnedStateAction: UIContextualAction + if isThreadPinned { + pinnedStateAction = UIContextualAction(style: .normal, title: nil) { [weak self] (_, _, completion) in + self?.unpinThread(threadViewModel: threadViewModel) + completion(false) + } + pinnedStateAction.backgroundColor = UIColor(rgbHex: 0xff990a) + pinnedStateAction.accessibilityLabel = CommonStrings.unpinAction + pinnedStateAction.image = actionImage(name: "unpin-solid-24", title: CommonStrings.unpinAction) + } else { + pinnedStateAction = UIContextualAction(style: .destructive, title: nil) { [weak self] (_, _, completion) in + completion(false) + self?.pinThread(threadViewModel: threadViewModel) + } + pinnedStateAction.backgroundColor = UIColor(rgbHex: 0xff990a) + pinnedStateAction.accessibilityLabel = CommonStrings.pinAction + pinnedStateAction.image = actionImage(name: "pin-solid-24", title: CommonStrings.pinAction) + } + + let readStateAction: UIContextualAction + if threadViewModel.hasUnreadMessages { + readStateAction = UIContextualAction(style: .destructive, title: nil) { [weak self] (_, _, completion) in + completion(false) + self?.markThreadAsRead(threadViewModel: threadViewModel) + } + readStateAction.backgroundColor = .ows_accentBlue + readStateAction.accessibilityLabel = CommonStrings.readAction + readStateAction.image = actionImage(name: "read-solid-24", title: CommonStrings.readAction) + } else { + readStateAction = UIContextualAction(style: .normal, title: nil) { [weak self] (_, _, completion) in + completion(false) + self?.markThreadAsUnread(threadViewModel: threadViewModel) + } + readStateAction.backgroundColor = .ows_accentBlue + readStateAction.accessibilityLabel = CommonStrings.unreadAction + readStateAction.image = actionImage(name: "unread-solid-24", title: CommonStrings.unreadAction) + } + + // The first action will be auto-performed for "very long swipes". + return UISwipeActionsConfiguration(actions: [ readStateAction, pinnedStateAction ]) + } + + func trailingSwipeActionsConfiguration(for threadViewModel: ThreadViewModel?, closeConversationBlock: (() -> Void)? = nil) -> UISwipeActionsConfiguration? { + AssertIsOnMainThread() + + guard let threadViewModel = threadViewModel else { + return nil + } + + let muteAction = UIContextualAction(style: .normal, title: nil) { [weak self] (_, _, completion) in + if threadViewModel.isMuted { + self?.unmuteThread(threadViewModel: threadViewModel) + } else { + self?.muteThreadWithSelection(threadViewModel: threadViewModel) + } + completion(false) + } + muteAction.backgroundColor = .ows_accentIndigo + muteAction.image = actionImage(name: threadViewModel.isMuted ? "bell-solid-24" : "bell-disabled-solid-24", + title: threadViewModel.isMuted ? CommonStrings.unmuteButton : CommonStrings.muteButton) + muteAction.accessibilityLabel = threadViewModel.isMuted ? CommonStrings.unmuteButton : CommonStrings.muteButton + + let deleteAction = UIContextualAction(style: .destructive, title: nil) { [weak self] (_, _, completion) in + self?.deleteThreadWithConfirmation(threadViewModel: threadViewModel, closeConversationBlock: closeConversationBlock) + completion(false) + } + deleteAction.backgroundColor = .ows_accentRed + deleteAction.image = actionImage(name: "trash-solid-24", title: CommonStrings.deleteButton) + deleteAction.accessibilityLabel = CommonStrings.deleteButton + + let archiveAction = UIContextualAction(style: .normal, title: nil) { [weak self] (_, _, completion) in + self?.archiveThread(threadViewModel: threadViewModel, closeConversationBlock: closeConversationBlock) + completion(false) + } + + let archiveTitle = threadViewModel.isArchived ? CommonStrings.unarchiveAction : CommonStrings.archiveAction + archiveAction.backgroundColor = Theme.isDarkThemeEnabled ? .ows_gray45 : .ows_gray25 + archiveAction.image = actionImage(name: "archive-solid-24", title: archiveTitle) + archiveAction.accessibilityLabel = archiveTitle + + // The first action will be auto-performed for "very long swipes". + return UISwipeActionsConfiguration(actions: [archiveAction, deleteAction, muteAction]) + } + + func actionImage(name imageName: String, title: String) -> UIImage? { + AssertIsOnMainThread() + // We need to bake the title text into the image because `UIContextualAction` + // only displays title + image when the cell's height > 91. We want to always + // show both. + guard let image = UIImage(named: imageName) else { + owsFailDebug("Missing image.") + return nil + } + guard let image = image.withTitle(title, + font: UIFont.systemFont(ofSize: 13), + color: .ows_white, + maxTitleWidth: 68, + minimumScaleFactor: CGFloat(8) / CGFloat(13), + spacing: 4) else { + owsFailDebug("Missing image.") + return nil + } + return image.withRenderingMode(.alwaysTemplate) + } + + fileprivate func archiveThread(threadViewModel: ThreadViewModel, closeConversationBlock: (() -> Void)?) { + AssertIsOnMainThread() + + closeConversationBlock?() + databaseStorage.write { transaction in + threadViewModel.associatedData.updateWith(isArchived: !threadViewModel.isArchived, + updateStorageService: true, + transaction: transaction) + } + updateUIAfterSwipeAction?() + } + + fileprivate func deleteThreadWithConfirmation(threadViewModel: ThreadViewModel, closeConversationBlock: (() -> Void)?) { + AssertIsOnMainThread() + + let alert = ActionSheetController(title: NSLocalizedString("CONVERSATION_DELETE_CONFIRMATION_ALERT_TITLE", + comment: "Title for the 'conversation delete confirmation' alert."), + message: NSLocalizedString("CONVERSATION_DELETE_CONFIRMATION_ALERT_MESSAGE", + comment: "Message for the 'conversation delete confirmation' alert.")) + alert.addAction(ActionSheetAction(title: CommonStrings.deleteButton, + style: .destructive) { [weak self] _ in + self?.deleteThread(threadViewModel: threadViewModel, closeConversationBlock: closeConversationBlock) + }) + alert.addAction(OWSActionSheets.cancelAction) + + presentActionSheet(alert) + } + + fileprivate func deleteThread(threadViewModel: ThreadViewModel, closeConversationBlock: (() -> Void)?) { + AssertIsOnMainThread() + + closeConversationBlock?() + databaseStorage.write { transaction in + threadViewModel.threadRecord.softDelete(with: transaction) + } + updateUIAfterSwipeAction?() + } + + fileprivate func markThreadAsRead(threadViewModel: ThreadViewModel) { + AssertIsOnMainThread() + + databaseStorage.write { transaction in + threadViewModel.threadRecord.markAllAsRead(updateStorageService: true, transaction: transaction) + } + } + + fileprivate func markThreadAsUnread(threadViewModel: ThreadViewModel) { + AssertIsOnMainThread() + + databaseStorage.write { transaction in + threadViewModel.associatedData.updateWith(isMarkedUnread: true, updateStorageService: true, transaction: transaction) + } + } + + fileprivate func muteThreadWithSelection(threadViewModel: ThreadViewModel) { + AssertIsOnMainThread() + + let alert = ActionSheetController(title: NSLocalizedString("CONVERSATION_MUTE_CONFIRMATION_ALERT_TITLE", + comment: "Title for the 'conversation mute confirmation' alert.")) + for (title, seconds) in [ + (NSLocalizedString("CONVERSATION_MUTE_CONFIRMATION_OPTION_1H", comment: "1 hour"), kHourInterval), + (NSLocalizedString("CONVERSATION_MUTE_CONFIRMATION_OPTION_8H", comment: "8 hours"), 8 * kHourInterval), + (NSLocalizedString("CONVERSATION_MUTE_CONFIRMATION_OPTION_1D", comment: "1 day"), kDayInterval), + (NSLocalizedString("CONVERSATION_MUTE_CONFIRMATION_OPTION_1W", comment: "1 week"), kWeekInterval), + (NSLocalizedString("CONVERSATION_MUTE_CONFIRMATION_OPTION_ALWAYS", comment: "Always"), -1)] { + alert.addAction(ActionSheetAction(title: title, style: .default) { [weak self] _ in + self?.muteThread(threadViewModel: threadViewModel, duration: seconds) + }) + } + alert.addAction(OWSActionSheets.cancelAction) + + presentActionSheet(alert) + } + + fileprivate func muteThread(threadViewModel: ThreadViewModel, duration seconds: TimeInterval) { + AssertIsOnMainThread() + + databaseStorage.write { transaction in + let timeStamp = seconds < 0 + ? ThreadAssociatedData.alwaysMutedTimestamp + : (seconds == 0 ? 0 : Date.ows_millisecondTimestamp() + UInt64(seconds * 1000)) + threadViewModel.associatedData.updateWith(mutedUntilTimestamp: timeStamp, updateStorageService: true, transaction: transaction) + } + } + + fileprivate func unmuteThread(threadViewModel: ThreadViewModel) { + AssertIsOnMainThread() + + databaseStorage.write { transaction in + threadViewModel.associatedData.updateWith(mutedUntilTimestamp: Date.ows_millisecondTimestamp(), updateStorageService: true, transaction: transaction) + } + } + + fileprivate func pinThread(threadViewModel: ThreadViewModel) { + AssertIsOnMainThread() + + do { + try databaseStorage.write { transaction in + try PinnedThreadManager.pinThread(threadViewModel.threadRecord, updateStorageService: true, transaction: transaction) + } + } catch { + if case PinnedThreadError.tooManyPinnedThreads = error { + OWSActionSheets.showActionSheet(title: NSLocalizedString("PINNED_CONVERSATION_LIMIT", + comment: "An explanation that you have already pinned the maximum number of conversations.")) + } else { + owsFailDebug("Error: \(error)") + } + } + } + + fileprivate func unpinThread(threadViewModel: ThreadViewModel) { + AssertIsOnMainThread() + + do { + try databaseStorage.write { transaction in + try PinnedThreadManager.unpinThread(threadViewModel.threadRecord, updateStorageService: true, transaction: transaction) + } + } catch { + owsFailDebug("Error: \(error)") + } + } + +} diff --git a/SignalNSE/Info.plist b/SignalNSE/Info.plist index 610dce0c38..18f4fb0a7a 100644 --- a/SignalNSE/Info.plist +++ b/SignalNSE/Info.plist @@ -19,7 +19,7 @@ CFBundleShortVersionString 5.27.0 CFBundleVersion - 38 + 39 NSAppTransportSecurity NSExceptionDomains @@ -55,7 +55,7 @@ $(PRODUCT_MODULE_NAME).NotificationService OWSBundleVersion4 - 5.27.0.38 + 5.27.0.39 UIAppFonts Inter-Medium.otf diff --git a/SignalShareExtension/Info.plist b/SignalShareExtension/Info.plist index b8015332c1..715e752b1d 100644 --- a/SignalShareExtension/Info.plist +++ b/SignalShareExtension/Info.plist @@ -19,7 +19,7 @@ CFBundleShortVersionString 5.27.0 CFBundleVersion - 38 + 39 ITSAppUsesNonExemptEncryption NSAppTransportSecurity @@ -77,7 +77,7 @@ com.apple.share-services OWSBundleVersion4 - 5.27.0.38 + 5.27.0.39 UIAppFonts fontawesome-webfont.ttf