diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index 7d611f2ac..9c3eb18e3 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -669,29 +669,20 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob label: "Block" ), confirmationInfo: ConfirmationModal.Info( - title: { - guard threadViewModel.threadIsBlocked == true else { - return String( - format: "block".localized(), - threadViewModel.displayName - ) - } - - return String( - format: "blockUnblock".localized(), - threadViewModel.displayName - ) - }(), + title: (threadViewModel.threadIsBlocked == true ? + "blockUnblock".localized() : + "block".localized() + ), body: (threadViewModel.threadIsBlocked == true ? .attributedText( "blockUnblockName" .put(key: "name", value: threadViewModel.displayName) - .localizedFormatted(baseFont: .systemFont(ofSize: Values.smallFontSize)) + .localizedFormatted(baseFont: ConfirmationModal.explanationFont) ) : .attributedText( "blockDescription" .put(key: "name", value: threadViewModel.displayName) - .localizedFormatted(baseFont: .systemFont(ofSize: Values.smallFontSize)) + .localizedFormatted(baseFont: ConfirmationModal.explanationFont) ) ), confirmTitle: (threadViewModel.threadIsBlocked == true ? diff --git a/Session/Home/GlobalSearch/GlobalSearchViewController.swift b/Session/Home/GlobalSearch/GlobalSearchViewController.swift index 1a09450cd..11e36a725 100644 --- a/Session/Home/GlobalSearch/GlobalSearchViewController.swift +++ b/Session/Home/GlobalSearch/GlobalSearchViewController.swift @@ -20,7 +20,7 @@ private extension Log.Category { class GlobalSearchViewController: BaseVC, LibSessionRespondingViewController, UITableViewDelegate, UITableViewDataSource { fileprivate typealias SectionModel = ArraySection - fileprivate struct SearchResultData { + fileprivate struct SearchResultData: Equatable { var state: SearchResultsState var data: [SectionModel] } @@ -50,70 +50,31 @@ class GlobalSearchViewController: BaseVC, LibSessionRespondingViewController, UI // MARK: - Variables private let dependencies: Dependencies - private lazy var defaultSearchResults: SearchResultData = { - let nonalphabeticNameTitle: String = "#" // stringlint:ignore - let contacts: [SessionThreadViewModel] = dependencies[singleton: .storage].read { [dependencies] db -> [SessionThreadViewModel]? in + private var defaultSearchResults: SearchResultData = SearchResultData(state: .none, data: []) { + didSet { + guard searchText.isEmpty else { return } + + /// If we have no search term then the contact list should be showing, so update the results and reload the table + self.searchResultSet = defaultSearchResults + + switch Thread.isMainThread { + case true: self.tableView.reloadData() + case false: DispatchQueue.main.async { self.tableView.reloadData() } + } + } + } + private lazy var defaultSearchResultsObservation = ValueObservation + .trackingConstantRegion { [dependencies] db -> [SessionThreadViewModel] in try SessionThreadViewModel .defaultContactsQuery(using: dependencies) .fetchAll(db) } - .defaulting(to: []) - .sorted { - $0.displayName.lowercased() < $1.displayName.lowercased() - } - - var groupedContacts: [String: SectionModel] = [:] - contacts.forEach { contactViewModel in - guard !contactViewModel.threadIsNoteToSelf else { - groupedContacts[""] = SectionModel( - model: .groupedContacts(title: ""), - elements: [contactViewModel] - ) - return - } - - let displayName = NSMutableString(string: contactViewModel.displayName) - CFStringTransform(displayName, nil, kCFStringTransformToLatin, false) - CFStringTransform(displayName, nil, kCFStringTransformStripDiacritics, false) - - let initialCharacter: String = (displayName.length > 0 ? displayName.substring(to: 1) : "") - let section: String = initialCharacter.capitalized.isSingleAlphabet ? - initialCharacter.capitalized : - nonalphabeticNameTitle - - if groupedContacts[section] == nil { - groupedContacts[section] = SectionModel( - model: .groupedContacts(title: section), - elements: [] - ) - } - groupedContacts[section]?.elements.append(contactViewModel) - } - - return SearchResultData( - state: .defaultContacts, - data: groupedContacts.values.sorted { sectionModel0, sectionModel1 in - let title0: String = { - switch sectionModel0.model { - case .groupedContacts(let title): return title - default: return "" - } - }() - let title1: String = { - switch sectionModel1.model { - case .groupedContacts(let title): return title - default: return "" - } - }() - - if ![title0, title1].contains(nonalphabeticNameTitle) { - return title0 < title1 - } - - return title1 == nonalphabeticNameTitle - } - ) - }() + .map { GlobalSearchViewController.processDefaultSearchResults($0) } + .removeDuplicates() + .handleEvents(didFail: { Log.error(.cat, "Observation failed with error: \($0)") }) + private var defaultDataChangeObservable: DatabaseCancellable? { + didSet { oldValue?.cancel() } // Cancel the old observable if there was one + } @ThreadSafeObject private var currentSearchCancellable: AnyCancellable? = nil private lazy var searchResultSet: SearchResultData = defaultSearchResults @@ -186,6 +147,18 @@ class GlobalSearchViewController: BaseVC, LibSessionRespondingViewController, UI navigationItem.hidesBackButton = true setupNavigationBar() } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + defaultDataChangeObservable = dependencies[singleton: .storage].start( + defaultSearchResultsObservation, + onError: { _ in }, + onChange: { [weak self] updatedDefaultResults in + self?.defaultSearchResults = updatedDefaultResults + } + ) + } public override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) @@ -195,6 +168,8 @@ class GlobalSearchViewController: BaseVC, LibSessionRespondingViewController, UI public override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) + self.defaultDataChangeObservable = nil + UIView.performWithoutAnimation { searchBar.resignFirstResponder() } @@ -240,6 +215,64 @@ class GlobalSearchViewController: BaseVC, LibSessionRespondingViewController, UI } // MARK: - Update Search Results + + private static func processDefaultSearchResults(_ contacts: [SessionThreadViewModel]) -> SearchResultData { + let nonalphabeticNameTitle: String = "#" // stringlint:ignore + + return SearchResultData( + state: .defaultContacts, + data: contacts + .sorted { lhs, rhs in lhs.displayName.lowercased() < rhs.displayName.lowercased() } + .reduce(into: [String: SectionModel]()) { result, next in + guard !next.threadIsNoteToSelf else { + result[""] = SectionModel( + model: .groupedContacts(title: ""), + elements: [next] + ) + return + } + + let displayName = NSMutableString(string: next.displayName) + CFStringTransform(displayName, nil, kCFStringTransformToLatin, false) + CFStringTransform(displayName, nil, kCFStringTransformStripDiacritics, false) + + let initialCharacter: String = (displayName.length > 0 ? displayName.substring(to: 1) : "") + let section: String = (initialCharacter.capitalized.isSingleAlphabet ? + initialCharacter.capitalized : + nonalphabeticNameTitle + ) + + if result[section] == nil { + result[section] = SectionModel( + model: .groupedContacts(title: section), + elements: [] + ) + } + result[section]?.elements.append(next) + } + .values + .sorted { sectionModel0, sectionModel1 in + let title0: String = { + switch sectionModel0.model { + case .groupedContacts(let title): return title + default: return "" + } + }() + let title1: String = { + switch sectionModel1.model { + case .groupedContacts(let title): return title + default: return "" + } + }() + + if ![title0, title1].contains(nonalphabeticNameTitle) { + return title0 < title1 + } + + return title1 == nonalphabeticNameTitle + } + ) + } private func refreshSearchResults() { refreshTimer?.invalidate() @@ -381,6 +414,32 @@ extension GlobalSearchViewController { ) } } + + public func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { + let section: SectionModel = self.searchResultSet.data[indexPath.section] + + switch section.model { + case .contactsAndGroups, .messages: return nil + case .groupedContacts: + let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row] + + /// No actions for `Note to Self` + guard !threadViewModel.threadIsNoteToSelf else { return nil } + + return UIContextualAction.configuration( + for: UIContextualAction.generateSwipeActions( + [.block, .deleteContact], + for: .trailing, + indexPath: indexPath, + tableView: tableView, + threadViewModel: threadViewModel, + viewController: self, + navigatableStateHolder: nil, + using: dependencies + ) + ) + } + } private func show( threadId: String, diff --git a/Session/Shared/FullConversationCell.swift b/Session/Shared/FullConversationCell.swift index 52255ebe0..dc9b721c7 100644 --- a/Session/Shared/FullConversationCell.swift +++ b/Session/Shared/FullConversationCell.swift @@ -13,7 +13,13 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC // MARK: - UI - private let accentLineView: UIView = UIView() + private let accentLineView: UIView = { + let result: UIView = UIView() + result.themeBackgroundColor = .conversationButton_unreadStripBackground + result.alpha = 0 + + return result + }() private lazy var profilePictureView: ProfilePictureView = ProfilePictureView(size: .list) @@ -430,15 +436,7 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC self.themeBackgroundColor = themeBackgroundColor self.selectedBackgroundView?.themeBackgroundColor = .highlighted(themeBackgroundColor) - if cellViewModel.threadIsBlocked == true { - accentLineView.themeBackgroundColor = .danger - accentLineView.alpha = 1 - } - else { - accentLineView.themeBackgroundColor = .conversationButton_unreadStripBackground - accentLineView.alpha = (unreadCount > 0 ? 1 : 0) - } - + accentLineView.alpha = (unreadCount > 0 ? 1 : 0) isPinnedIcon.isHidden = (cellViewModel.threadPinnedPriority == 0) unreadCountView.isHidden = (unreadCount <= 0) unreadImageView.isHidden = (!unreadCountView.isHidden || !threadIsUnread) @@ -530,7 +528,6 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC public func optimisticUpdate( isMuted: Bool?, - isBlocked: Bool?, isPinned: Bool?, hasUnread: Bool? ) { @@ -557,17 +554,6 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC } } - if let isBlocked: Bool = isBlocked { - if isBlocked { - accentLineView.themeBackgroundColor = .danger - accentLineView.alpha = 1 - } - else { - accentLineView.themeBackgroundColor = .conversationButton_unreadStripBackground - accentLineView.alpha = (!unreadCountView.isHidden || !unreadImageView.isHidden ? 1 : 0) - } - } - if let isPinned: Bool = isPinned { isPinnedIcon.isHidden = !isPinned } diff --git a/Session/Utilities/UIContextualAction+Utilities.swift b/Session/Utilities/UIContextualAction+Utilities.swift index 3265399c4..e094e5c89 100644 --- a/Session/Utilities/UIContextualAction+Utilities.swift +++ b/Session/Utilities/UIContextualAction+Utilities.swift @@ -8,24 +8,20 @@ import SessionUIKit import SessionUtilitiesKit protocol SwipeActionOptimisticCell { - func optimisticUpdate(isMuted: Bool?, isBlocked: Bool?, isPinned: Bool?, hasUnread: Bool?) + func optimisticUpdate(isMuted: Bool?, isPinned: Bool?, hasUnread: Bool?) } extension SwipeActionOptimisticCell { public func optimisticUpdate(isMuted: Bool) { - optimisticUpdate(isMuted: isMuted, isBlocked: nil, isPinned: nil, hasUnread: nil) - } - - public func optimisticUpdate(isBlocked: Bool) { - optimisticUpdate(isMuted: nil, isBlocked: isBlocked, isPinned: nil, hasUnread: nil) + optimisticUpdate(isMuted: isMuted, isPinned: nil, hasUnread: nil) } public func optimisticUpdate(isPinned: Bool) { - optimisticUpdate(isMuted: nil, isBlocked: nil, isPinned: isPinned, hasUnread: nil) + optimisticUpdate(isMuted: nil, isPinned: isPinned, hasUnread: nil) } public func optimisticUpdate(hasUnread: Bool) { - optimisticUpdate(isMuted: nil, isBlocked: nil, isPinned: nil, hasUnread: hasUnread) + optimisticUpdate(isMuted: nil, isPinned: nil, hasUnread: hasUnread) } } @@ -38,6 +34,7 @@ public extension UIContextualAction { case block case leave case delete + case deleteContact case clear } @@ -370,102 +367,110 @@ public extension UIContextualAction { (!threadIsContactMessageRequest ? nil : Contact.Columns.didApproveMe.set(to: true)), (!threadIsContactMessageRequest ? nil : Contact.Columns.isApproved.set(to: false)) ].compactMap { $0 } + let nameToUse: String = { + switch threadViewModel.threadVariant { + case .group: + return Profile.displayName( + for: .contact, + id: profileInfo.id, + name: profileInfo.profile?.name, + nickname: profileInfo.profile?.nickname, + suppressId: false + ) + + default: return threadViewModel.displayName + } + }() - let performBlock: (UIViewController?) -> () = { viewController in - (tableView.cellForRow(at: indexPath) as? SwipeActionOptimisticCell)? - .optimisticUpdate( - isBlocked: !threadIsBlocked - ) - completionHandler(true) - - // Delay the change to give the cell "unswipe" animation some time to complete - DispatchQueue.global(qos: .default).asyncAfter(deadline: .now() + unswipeAnimationDelay) { - dependencies[singleton: .storage] - .writePublisher { db in - // Create the contact if it doesn't exist - switch threadViewModel.threadVariant { - case .contact: - try Contact - .fetchOrCreate(db, id: threadViewModel.threadId, using: dependencies) - .upsert(db) - try Contact - .filter(id: threadViewModel.threadId) - .updateAllAndConfig( - db, - contactChanges, - using: dependencies - ) + let confirmationModal: ConfirmationModal = ConfirmationModal( + info: ConfirmationModal.Info( + title: (threadIsBlocked ? + "blockUnblock".localized() : + "block".localized() + ), + body: (threadIsBlocked ? + .attributedText( + "blockUnblockName" + .put(key: "name", value: nameToUse) + .localizedFormatted(baseFont: ConfirmationModal.explanationFont) + ) : + .attributedText( + "blockDescription" + .put(key: "name", value: nameToUse) + .localizedFormatted(baseFont: ConfirmationModal.explanationFont) + ) + ), + confirmTitle: (threadIsBlocked ? + "blockUnblock".localized() : + "block".localized() + ), + confirmStyle: .danger, + cancelStyle: .alert_text, + dismissOnConfirm: true, + onConfirm: { _ in + completionHandler(true) + + // Delay the change to give the cell "unswipe" animation some time to complete + DispatchQueue.global(qos: .default).asyncAfter(deadline: .now() + unswipeAnimationDelay) { + dependencies[singleton: .storage] + .writePublisher { db in + // Create the contact if it doesn't exist + switch threadViewModel.threadVariant { + case .contact: + try Contact + .fetchOrCreate( + db, + id: threadViewModel.threadId, + using: dependencies + ) + .upsert(db) + try Contact + .filter(id: threadViewModel.threadId) + .updateAllAndConfig( + db, + contactChanges, + using: dependencies + ) + + case .group: + try Contact + .fetchOrCreate( + db, + id: profileInfo.id, + using: dependencies + ) + .upsert(db) + try Contact + .filter(id: profileInfo.id) + .updateAllAndConfig( + db, + contactChanges, + using: dependencies + ) + + default: break + } - case .group: - try Contact - .fetchOrCreate(db, id: profileInfo.id, using: dependencies) - .upsert(db) - try Contact - .filter(id: profileInfo.id) - .updateAllAndConfig( + // Blocked message requests should be deleted + if threadViewModel.threadIsMessageRequest == true { + try SessionThread.deleteOrLeave( db, - contactChanges, + type: .deleteContactConversationAndMarkHidden, + threadId: threadViewModel.threadId, + threadVariant: threadViewModel.threadVariant, using: dependencies ) - - default: break - } - - // Blocked message requests should be deleted - if threadViewModel.threadIsMessageRequest == true { - try SessionThread.deleteOrLeave( - db, - type: .deleteContactConversationAndMarkHidden, - threadId: threadViewModel.threadId, - threadVariant: threadViewModel.threadVariant, - using: dependencies - ) - } - } - .subscribe(on: DispatchQueue.global(qos: .userInitiated)) - .sinkUntilComplete() - } - } - - switch threadViewModel.threadIsMessageRequest == true { - case false: performBlock(nil) - case true: - let nameToUse: String = { - switch threadViewModel.threadVariant { - case .group: - return Profile.displayName( - for: .contact, - id: profileInfo.id, - name: profileInfo.profile?.name, - nickname: profileInfo.profile?.nickname, - suppressId: false - ) - - default: return threadViewModel.displayName + } + } + .subscribe(on: DispatchQueue.global(qos: .userInitiated)) + .sinkUntilComplete() } - }() - - let confirmationModal: ConfirmationModal = ConfirmationModal( - info: ConfirmationModal.Info( - title: "block".localized(), - body: .attributedText( - "blockDescription" - .put(key: "name", value: nameToUse) - .localizedFormatted(baseFont: .systemFont(ofSize: Values.smallFontSize)) - ), - confirmTitle: "block".localized(), - confirmStyle: .danger, - cancelStyle: .alert_text, - dismissOnConfirm: true, - onConfirm: { _ in - performBlock(viewController) - }, - afterClosed: { completionHandler(false) } - ) - ) - - viewController?.present(confirmationModal, animated: true, completion: nil) - } + }, + afterClosed: { completionHandler(false) } + ) + ) + + viewController?.present(confirmationModal, animated: true, completion: nil) } // MARK: -- leave @@ -671,6 +676,52 @@ public extension UIContextualAction { ) ) + viewController?.present(confirmationModal, animated: true, completion: nil) + } + + // MARK: -- deleteContact + + case .deleteContact: + return UIContextualAction( + title: "contactDelete".localized(), + icon: Lucide.image(icon: .trash2, size: 24, color: .white), + themeTintColor: .white, + themeBackgroundColor: themeBackgroundColor, + accessibility: Accessibility(identifier: "Delete button"), + side: side, + actionIndex: targetIndex, + indexPath: indexPath, + tableView: tableView + ) { [weak viewController] _, _, completionHandler in + let confirmationModal: ConfirmationModal = ConfirmationModal( + info: ConfirmationModal.Info( + title: "contactDelete".localized(), + body: .attributedText( + "contactDeleteDescription" + .put(key: "name", value: threadViewModel.displayName) + .localizedFormatted(baseFont: .boldSystemFont(ofSize: Values.smallFontSize)) + ), + confirmTitle: "delete".localized(), + confirmStyle: .danger, + cancelStyle: .alert_text, + dismissOnConfirm: true, + onConfirm: { _ in + dependencies[singleton: .storage].writeAsync { db in + try SessionThread.deleteOrLeave( + db, + type: .deleteContactConversationAndContact, + threadId: threadViewModel.threadId, + threadVariant: threadViewModel.threadVariant, + using: dependencies + ) + } + + completionHandler(true) + }, + afterClosed: { completionHandler(false) } + ) + ) + viewController?.present(confirmationModal, animated: true, completion: nil) } } diff --git a/SessionMessagingKit/Database/Models/SessionThread.swift b/SessionMessagingKit/Database/Models/SessionThread.swift index 1878106a9..9e0e19ebf 100644 --- a/SessionMessagingKit/Database/Models/SessionThread.swift +++ b/SessionMessagingKit/Database/Models/SessionThread.swift @@ -534,9 +534,18 @@ public extension SessionThread { try LibSession.hide(db, contactIds: Array(remainingThreadIds), using: dependencies) case .deleteContactConversationAndContact: - // Remove the contact from the config + // Remove the contact from the config (also need to clear the nickname since that's + // custom data for this contact) try LibSession.remove(db, contactIds: Array(remainingThreadIds), using: dependencies) + _ = try Profile + .filter(ids: remainingThreadIds) + .updateAll(db, Profile.Columns.nickname.set(to: nil)) + + _ = try Contact + .filter(ids: remainingThreadIds) + .deleteAll(db) + _ = try SessionThread .filter(ids: remainingThreadIds) .deleteAll(db) diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift index 0c4120a87..065b52a25 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift @@ -131,6 +131,12 @@ extension MessageReceiver { } // Update the `didApproveMe` state of the sender + let senderHadAlreadyApprovedMe: Bool = (try? Contact + .select(.didApproveMe) + .filter(id: senderId) + .asRequest(of: Bool.self) + .fetchOne(db)) + .defaulting(to: false) try updateContactApprovalStatusIfNeeded( db, senderSessionId: senderId, @@ -154,23 +160,29 @@ extension MessageReceiver { ) } - // Notify the user of their approval (Note: This will always appear in the un-blinded thread) - // - // Note: We want to do this last as it'll mean the un-blinded thread gets updated and the - // contact approval status will have been updated at this point (which will mean the - // `isMessageRequest` will return correctly after this is saved) - _ = try Interaction( - serverHash: message.serverHash, - threadId: unblindedThread.id, - threadVariant: unblindedThread.variant, - authorId: senderId, - variant: .infoMessageRequestAccepted, - timestampMs: ( - message.sentTimestampMs.map { Int64($0) } ?? - dependencies[cache: .snodeAPI].currentOffsetTimestampMs() - ), - using: dependencies - ).inserted(db) + /// Notify the user of their approval + /// + /// We want to do this last as it'll mean the un-blinded thread gets updated and the contact approval status will have been + /// updated at this point (which will mean the `isMessageRequest` will return correctly after this is saved) + /// + /// **Notes:** + /// - We only want to add the control message if the sender hadn't already approved the current user (this is to prevent spam + /// if the sender deletes and re-accepts message requests from the current user) + /// - This will always appear in the un-blinded thread + if !senderHadAlreadyApprovedMe { + _ = try Interaction( + serverHash: message.serverHash, + threadId: unblindedThread.id, + threadVariant: unblindedThread.variant, + authorId: senderId, + variant: .infoMessageRequestAccepted, + timestampMs: ( + message.sentTimestampMs.map { Int64($0) } ?? + dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + ), + using: dependencies + ).inserted(db) + } } internal static func updateContactApprovalStatusIfNeeded( diff --git a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift index 0e7f4615a..99e96e924 100644 --- a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift +++ b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift @@ -2085,6 +2085,7 @@ public extension SessionThreadViewModel { FROM \(Contact.self) LEFT JOIN \(thread) ON \(thread[.id]) = \(contact[.id]) LEFT JOIN \(contactProfile) ON \(contactProfile[.id]) = \(contact[.id]) + WHERE \(contact[.isBlocked]) = false """ // Add adapters which will group the various 'Profile' columns so they can be decoded