diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 982f262d3..2e9e3eb39 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -169,17 +169,16 @@ 7BFD1A8A2745C4F000FB91B9 /* Permissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BFD1A892745C4F000FB91B9 /* Permissions.swift */; }; 7BFD1A8C2747150E00FB91B9 /* TurnServerInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BFD1A8B2747150E00FB91B9 /* TurnServerInfo.swift */; }; 7BFD1A972747689000FB91B9 /* Session-Turn-Server in Resources */ = {isa = PBXBuildFile; fileRef = 7BFD1A962747689000FB91B9 /* Session-Turn-Server */; }; + 9422EE2B2B8C3A97004C740D /* String+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9422EE2A2B8C3A97004C740D /* String+Utilities.swift */; }; 942C9CA22B67769000B5153A /* SessionSearchBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 942C9CA12B67769000B5153A /* SessionSearchBar.swift */; }; - 942C9CA42B6868B800B5153A /* GlobalSearchScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 942C9CA32B6868B800B5153A /* GlobalSearchScreen.swift */; }; 943C6D762B705B7D004ACE64 /* CompatibleScrollingVStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = 943C6D752B705B7D004ACE64 /* CompatibleScrollingVStack.swift */; }; + 943C6D822B75E061004ACE64 /* Message+DisappearingMessages.swift in Sources */ = {isa = PBXBuildFile; fileRef = 943C6D812B75E061004ACE64 /* Message+DisappearingMessages.swift */; }; 946B34472B5DF0B7004CB4A3 /* QRCodeScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 946B34462B5DF0B7004CB4A3 /* QRCodeScreen.swift */; }; 946B34492B5E04BB004CB4A3 /* CustomTopTabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 946B34482B5E04BB004CB4A3 /* CustomTopTabBar.swift */; }; 946B344B2B5E08F3004CB4A3 /* ScanQRCodeScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 946B344A2B5E08F3004CB4A3 /* ScanQRCodeScreen.swift */; }; 946B344D2B5F67B4004CB4A3 /* StartConversationScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 946B344C2B5F67B4004CB4A3 /* StartConversationScreen.swift */; }; 946B344F2B61D80B004CB4A3 /* InviteAFriendScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 946B344E2B61D80B004CB4A3 /* InviteAFriendScreen.swift */; }; 946B34512B61D818004CB4A3 /* NewMessageScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 946B34502B61D818004CB4A3 /* NewMessageScreen.swift */; }; - 9422EE2B2B8C3A97004C740D /* String+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9422EE2A2B8C3A97004C740D /* String+Utilities.swift */; }; - 943C6D822B75E061004ACE64 /* Message+DisappearingMessages.swift in Sources */ = {isa = PBXBuildFile; fileRef = 943C6D812B75E061004ACE64 /* Message+DisappearingMessages.swift */; }; 9593A1E796C9E6BE2352EA6F /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_SessionSnodeKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F8B0BA5257C58DC6FF797278 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_SessionSnodeKit.framework */; }; 99978E3F7A80275823CA9014 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_SessionNotificationServiceExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 29E827FDF6C1032BB985740C /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_SessionNotificationServiceExtension.framework */; }; A11CD70D17FA230600A2D1B1 /* QuartzCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A11CD70C17FA230600A2D1B1 /* QuartzCore.framework */; }; @@ -1230,7 +1229,6 @@ 70377AAA1918450100CAF501 /* MobileCoreServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MobileCoreServices.framework; path = System/Library/Frameworks/MobileCoreServices.framework; sourceTree = SDKROOT; }; 76C87F18181EFCE600C4ACAB /* MediaPlayer.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MediaPlayer.framework; path = System/Library/Frameworks/MediaPlayer.framework; sourceTree = SDKROOT; }; 7B02DF432A16F47B00ADCFD2 /* EmojiGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiGenerator.swift; sourceTree = ""; }; - 7B02DF442A16F47B00ADCFD2 /* build_libSession_util.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = build_libSession_util.sh; sourceTree = ""; }; 7B0EFDED274F598600FFAAE7 /* TimestampUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimestampUtils.swift; sourceTree = ""; }; 7B0EFDEF275084AA00FFAAE7 /* CallMessageCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallMessageCell.swift; sourceTree = ""; }; 7B0EFDF3275490EA00FFAAE7 /* ringing.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = ringing.mp3; sourceTree = ""; }; @@ -1325,17 +1323,16 @@ 8E946CB54A221018E23599DE /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig"; path = "Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig"; sourceTree = ""; }; 92E8569C96285EE3CDB5960D /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SignalUtilitiesKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SignalUtilitiesKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 93359C81CF2660040B7CD106 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionUtilitiesKit_SessionUtilitiesKitTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionUtilitiesKit_SessionUtilitiesKitTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 9422EE2A2B8C3A97004C740D /* String+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Utilities.swift"; sourceTree = ""; }; 942C9CA12B67769000B5153A /* SessionSearchBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionSearchBar.swift; sourceTree = ""; }; - 942C9CA32B6868B800B5153A /* GlobalSearchScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalSearchScreen.swift; sourceTree = ""; }; 943C6D752B705B7D004ACE64 /* CompatibleScrollingVStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompatibleScrollingVStack.swift; sourceTree = ""; }; + 943C6D812B75E061004ACE64 /* Message+DisappearingMessages.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Message+DisappearingMessages.swift"; sourceTree = ""; }; 946B34462B5DF0B7004CB4A3 /* QRCodeScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeScreen.swift; sourceTree = ""; }; 946B34482B5E04BB004CB4A3 /* CustomTopTabBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTopTabBar.swift; sourceTree = ""; }; 946B344A2B5E08F3004CB4A3 /* ScanQRCodeScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanQRCodeScreen.swift; sourceTree = ""; }; 946B344C2B5F67B4004CB4A3 /* StartConversationScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartConversationScreen.swift; sourceTree = ""; }; 946B344E2B61D80B004CB4A3 /* InviteAFriendScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InviteAFriendScreen.swift; sourceTree = ""; }; 946B34502B61D818004CB4A3 /* NewMessageScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewMessageScreen.swift; sourceTree = ""; }; - 9422EE2A2B8C3A97004C740D /* String+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Utilities.swift"; sourceTree = ""; }; - 943C6D812B75E061004ACE64 /* Message+DisappearingMessages.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Message+DisappearingMessages.swift"; sourceTree = ""; }; A11CD70C17FA230600A2D1B1 /* QuartzCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuartzCore.framework; path = System/Library/Frameworks/QuartzCore.framework; sourceTree = SDKROOT; }; A163E8AA16F3F6A90094D68B /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; }; A1C32D4D17A0652C000A904E /* AddressBook.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AddressBook.framework; path = System/Library/Frameworks/AddressBook.framework; sourceTree = SDKROOT; }; @@ -2208,13 +2205,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - C8BB5A4618641C387640AE22 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; D221A086169C9E5E00537ABF /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -2498,7 +2488,6 @@ children = ( 7BAF54CC27ACCEEC003D12F8 /* GlobalSearchViewController.swift */, 7BAF54CD27ACCEEC003D12F8 /* EmptySearchResultCell.swift */, - 942C9CA32B6868B800B5153A /* GlobalSearchScreen.swift */, ); path = GlobalSearch; sourceTree = ""; @@ -2768,7 +2757,6 @@ 4CA46F4B219CCC630038ABDE /* CaptionView.swift */, 34F308A01ECB469700BB7697 /* OWSBezierPathView.h */, 34F308A11ECB469700BB7697 /* OWSBezierPathView.m */, - 7BAFA1182A39669400B76CB9 /* BezierPathView.swift */, C354E75923FE2A7600CE22E3 /* BaseVC.swift */, B8BB82AA238F669C00BA5194 /* FullConversationCell.swift */, 4542DF53208D40AC007B4E76 /* LoadingViewController.swift */, @@ -6325,7 +6313,6 @@ B877E24226CA12910007970A /* CallVC.swift in Sources */, FDC498B92AC15FE300EDD897 /* AppNotificationAction.swift in Sources */, 7BA6890D27325CCC00EFC32F /* SessionCallManager+CXCallController.swift in Sources */, - 942C9CA42B6868B800B5153A /* GlobalSearchScreen.swift in Sources */, 7B71A98F2925E2A600E54854 /* SessionFooterView.swift in Sources */, C374EEEB25DA3CA70073A857 /* ConversationTitleView.swift in Sources */, FD716E7128505E5200C96BF4 /* MessageRequestsCell.swift in Sources */, @@ -6348,7 +6335,6 @@ 7BAF54CF27ACCEEC003D12F8 /* GlobalSearchViewController.swift in Sources */, FD37EA1728AC5605003AE748 /* NotificationContentViewModel.swift in Sources */, B886B4A72398B23E00211ABE /* QRCodeVC.swift in Sources */, - 4C586926224FAB83003FD070 /* AVAudioSession+OWS.m in Sources */, 7B87EF4C2A933355002A0E8F /* LoadingScreen.swift in Sources */, 4C4AE6A1224AF35700D4AF6F /* SendMediaNavigationController.swift in Sources */, B82149C125D605C6009C0F2A /* InfoBanner.swift in Sources */, @@ -6428,7 +6414,6 @@ 7B93D07727CF1A8A00811CB6 /* MockDataGenerator.swift in Sources */, 7B1B52D828580C6D006069F2 /* EmojiPickerSheet.swift in Sources */, FD368A6A29DE9E30000DBF1E /* UIContextualAction+Utilities.swift in Sources */, - 7BAFA1192A39669400B76CB9 /* BezierPathView.swift in Sources */, 7B4C75CD26BB92060000AC89 /* DeletedMessageView.swift in Sources */, FDD250722837234B00198BDA /* MediaGalleryNavigationController.swift in Sources */, FDD2506E283711D600198BDA /* DifferenceKit+Utilities.swift in Sources */, @@ -6444,7 +6429,6 @@ B8EB20F02640F7F000773E52 /* OpenGroupInvitationView.swift in Sources */, C328254025CA55880062D0A7 /* ContextMenuVC.swift in Sources */, 7BD687D12A5D0D1200D8E455 /* MessageInfoView.swift in Sources */, - 3427C64320F500E000EEC730 /* OWSMessageTimerView.m in Sources */, B8269D2925C7A4B400488AB4 /* InputView.swift in Sources */, FD71162E28E168C700B47552 /* SettingsViewModel.swift in Sources */, ); diff --git a/Session/Home/GlobalSearch/GlobalSearchScreen.swift b/Session/Home/GlobalSearch/GlobalSearchScreen.swift deleted file mode 100644 index ec5fc42d7..000000000 --- a/Session/Home/GlobalSearch/GlobalSearchScreen.swift +++ /dev/null @@ -1,520 +0,0 @@ -// Copyright © 2024 Rangeproof Pty Ltd. All rights reserved. - -import SwiftUI -import GRDB -import DifferenceKit -import SessionUIKit -import SessionMessagingKit -import SessionUtilitiesKit -import SignalUtilitiesKit -import SignalCoreKit - -enum SearchSection: Int, Differentiable { - case noResults - case contactsAndGroups - case messages - case defaultContacts -} - -struct GlobalSearchScreen: View { - fileprivate typealias SectionModel = ArraySection - - struct SectionData { - var sectionName: String - var contacts: [SessionThreadViewModel] - } - - @EnvironmentObject var host: HostWrapper - - @State private var searchText: String = "" - @State private var searchResultSet: [SectionModel] = Self.defaultSearchResults - @State private var readConnection: Atomic = Atomic(nil) - @State private var termForCurrentSearchResultSet: String = "" - @State private var lastSearchText: String? - - fileprivate static var defaultSearchResults: [SectionModel] = { - let result: [SessionThreadViewModel]? = Storage.shared.read { db -> [SessionThreadViewModel]? in - try SessionThreadViewModel - .defaultContactsQuery(userPublicKey: getUserHexEncodedPublicKey(db)) - .fetchAll(db) - } - - return [ result.map { ArraySection(model: .defaultContacts, elements: $0) } ] - .compactMap { $0 } - }() - - fileprivate var defaultGroupedContacts: [SectionData] = { - let contacts = Self.defaultSearchResults[0].elements - var groupedContacts: [String: SectionData] = [:] - contacts.forEach { contactViewModel in - guard !contactViewModel.threadIsNoteToSelf else { - groupedContacts[""] = SectionData( - sectionName: "", - contacts: [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 : - "Unknown" - - if groupedContacts[section] == nil { - groupedContacts[section] = SectionData( - sectionName: section, - contacts: [] - ) - } - groupedContacts[section]?.contacts.append(contactViewModel) - } - - return groupedContacts.values.sorted { - if $0.sectionName.count != $1.sectionName.count { - return $0.sectionName.count < $1.sectionName.count - } - return $0.sectionName < $1.sectionName - } - }() - - var body: some View { - VStack(alignment: .leading) { - SessionSearchBar( - searchText: $searchText.onChange{ updatedSearchText in - onSearchTextChange(rawSearchText: updatedSearchText) - }, - cancelAction: { - self.host.controller?.navigationController?.popViewController(animated: true) - } - ) - - CompatibleScrollingVStack( - alignment: .leading - ) { - ForEach(0.. 0 { - let sectionTitle: String = section.model == .contactsAndGroups ? "CONVERSATION_SETTINGS_TITLE".localized() : "SEARCH_SECTION_MESSAGES".localized() - Section( - header: Text(sectionTitle) - .bold() - .font(.system(size: Values.mediumLargeFontSize)) - .foregroundColor(themeColor: .textPrimary) - .padding(.horizontal, Values.mediumSpacing + Values.verySmallSpacing) - .padding(.top, Values.verySmallSpacing) - ) { - ForEach(0.. 0 else { - guard searchText != (lastSearchText ?? "") else { return } - - searchResultSet = Self.defaultSearchResults - lastSearchText = nil - return - } - guard force || lastSearchText != searchText else { return } - - lastSearchText = searchText - - DispatchQueue.global(qos: .default).async { - self.readConnection.wrappedValue?.interrupt() - - let result: Result<[SectionModel], Error>? = Storage.shared.read { db -> Result<[SectionModel], Error> in - self.readConnection.mutate { $0 = db } - - do { - let userPublicKey: String = getUserHexEncodedPublicKey(db) - let contactsResults: [SessionThreadViewModel] = try SessionThreadViewModel - .contactsAndGroupsQuery( - userPublicKey: userPublicKey, - pattern: try SessionThreadViewModel.pattern(db, searchTerm: searchText), - searchTerm: searchText - ) - .fetchAll(db) - let messageResults: [SessionThreadViewModel] = try SessionThreadViewModel - .messagesQuery( - userPublicKey: userPublicKey, - pattern: try SessionThreadViewModel.pattern(db, searchTerm: searchText) - ) - .fetchAll(db) - - return .success([ - ArraySection(model: .contactsAndGroups, elements: contactsResults), - ArraySection(model: .messages, elements: messageResults) - ]) - } - catch { - // Don't log the 'interrupt' error as that's just the user typing too fast - if (error as? DatabaseError)?.resultCode != DatabaseError.SQLITE_INTERRUPT { - SNLog("[GlobalSearch] Failed to find results due to error: \(error)") - } - - return .failure(error) - } - } - - DispatchQueue.main.async { - switch result { - case .success(let sections): - let hasResults: Bool = ( - !searchText.isEmpty && - (sections.map { $0.elements.count }.reduce(0, +) > 0) - ) - - self.termForCurrentSearchResultSet = searchText - self.searchResultSet = [ - (hasResults ? nil : [ - ArraySection( - model: .noResults, - elements: [ - SessionThreadViewModel(threadId: SessionThreadViewModel.invalidId) - ] - ) - ]), - (hasResults ? sections : nil) - ] - .compactMap { $0 } - .flatMap { $0 } - - default: break - } - } - } - } - - private func show(threadId: String, threadVariant: SessionThread.Variant, focusedInteractionInfo: Interaction.TimestampInfo? = nil, animated: Bool = true) { - guard Thread.isMainThread else { - DispatchQueue.main.async { - self.show(threadId: threadId, threadVariant: threadVariant, focusedInteractionInfo: focusedInteractionInfo, animated: animated) - } - return - } - - // If it's a one-to-one thread then make sure the thread exists before pushing to it (in case the - // contact has been hidden) - if threadVariant == .contact { - Storage.shared.write { db in - try SessionThread.fetchOrCreate( - db, - id: threadId, - variant: threadVariant, - shouldBeVisible: nil // Don't change current state - ) - } - } - - let viewController: ConversationVC = ConversationVC( - threadId: threadId, - threadVariant: threadVariant, - focusedInteractionInfo: focusedInteractionInfo - ) - self.host.controller?.navigationController?.pushViewController(viewController, animated: true) - } -} - -struct SearchResultCell: View { - var searchText: String - var searchSection: SearchSection - var viewModel: SessionThreadViewModel - var action: () -> Void - - var body: some View { - Button { - action() - } label: { - HStack( - alignment: .center, - spacing: Values.mediumSpacing - ) { - let size: ProfilePictureView.Size = .list - - ProfilePictureSwiftUI( - size: size, - publicKey: viewModel.threadId, - threadVariant: viewModel.threadVariant, - customImageData: viewModel.openGroupProfilePictureData, - profile: viewModel.profile, - additionalProfile: viewModel.additionalProfile - ) - .frame( - width: size.viewSize, - height: size.viewSize, - alignment: .topLeading - ) - .padding(.vertical, Values.smallSpacing) - - VStack( - alignment: .leading, - spacing: Values.verySmallSpacing - ) { - HStack { - Text(viewModel.displayName) - .bold() - .font(.system(size: Values.mediumFontSize)) - .foregroundColor(themeColor: .textPrimary) - - Spacer() - - if searchSection == .messages { - Text(viewModel.lastInteractionDate.formattedForDisplay) - .font(.system(size: Values.smallFontSize)) - .foregroundColor(themeColor: .textSecondary) - .opacity(Values.lowOpacity) - } - } - - if let textColor: UIColor = ThemeManager.currentTheme.color(for: .textPrimary) { - let maybeSnippet: NSAttributedString? = { - switch searchSection { - case .noResults, .defaultContacts: - return nil - case .contactsAndGroups: - switch viewModel.threadVariant { - case .contact, .community: return nil - case .legacyGroup, .group: - return self.getHighlightedSnippet( - content: (viewModel.threadMemberNames ?? ""), - currentUserPublicKey: viewModel.currentUserPublicKey, - currentUserBlinded15PublicKey: viewModel.currentUserBlinded15PublicKey, - currentUserBlinded25PublicKey: viewModel.currentUserBlinded25PublicKey, - searchText: searchText.lowercased(), - fontSize: Values.smallFontSize, - textColor: textColor - ) - } - case .messages: - return self.getHighlightedSnippet( - content: Interaction.previewText( - variant: (viewModel.interactionVariant ?? .standardIncoming), - body: viewModel.interactionBody, - authorDisplayName: viewModel.authorName(for: .contact), - attachmentDescriptionInfo: viewModel.interactionAttachmentDescriptionInfo, - attachmentCount: viewModel.interactionAttachmentCount, - isOpenGroupInvitation: (viewModel.interactionIsOpenGroupInvitation == true) - ), - authorName: (viewModel.authorId != viewModel.currentUserPublicKey ? - viewModel.authorName(for: .contact) : - nil - ), - currentUserPublicKey: viewModel.currentUserPublicKey, - currentUserBlinded15PublicKey: viewModel.currentUserBlinded15PublicKey, - currentUserBlinded25PublicKey: viewModel.currentUserBlinded25PublicKey, - searchText: searchText.lowercased(), - fontSize: Values.smallFontSize, - textColor: textColor - ) - } - }() - - if let snippet = maybeSnippet { - AttributedText(snippet).lineLimit(1) - } - } - } - - Spacer(minLength: 0) - } - .padding(.leading, Values.mediumSpacing) - } - } - - private func getHighlightedSnippet( - content: String, - authorName: String? = nil, - currentUserPublicKey: String, - currentUserBlinded15PublicKey: String?, - currentUserBlinded25PublicKey: String?, - searchText: String, - fontSize: CGFloat, - textColor: UIColor - ) -> NSAttributedString { - guard !content.isEmpty, content != "NOTE_TO_SELF".localized() else { - return NSMutableAttributedString( - string: (authorName != nil && authorName?.isEmpty != true ? - "\(authorName ?? ""): \(content)" : - content - ), - attributes: [ .foregroundColor: textColor ] - ) - } - - // Replace mentions in the content - // - // Note: The 'threadVariant' is used for profile context but in the search results - // we don't want to include the truncated id as part of the name so we exclude it - let mentionReplacedContent: String = MentionUtilities.highlightMentionsNoAttributes( - in: content, - threadVariant: .contact, - currentUserPublicKey: currentUserPublicKey, - currentUserBlinded15PublicKey: currentUserBlinded15PublicKey, - currentUserBlinded25PublicKey: currentUserBlinded25PublicKey - ) - let result: NSMutableAttributedString = NSMutableAttributedString( - string: mentionReplacedContent, - attributes: [ - .foregroundColor: textColor - .withAlphaComponent(Values.lowOpacity) - ] - ) - - // Bold each part of the searh term which matched - let normalizedSnippet: String = mentionReplacedContent.lowercased() - var firstMatchRange: Range? - - SessionThreadViewModel.searchTermParts(searchText) - .map { part -> String in - guard part.hasPrefix("\"") && part.hasSuffix("\"") else { return part } - - return part.trimmingCharacters(in: CharacterSet(charactersIn: "\"")) - } - .forEach { part in - // Highlight all ranges of the text (Note: The search logic only finds results that start - // with the term so we use the regex below to ensure we only highlight those cases) - normalizedSnippet - .ranges( - of: (Singleton.appContext.isRTL ? - "(\(part.lowercased()))(^|[^a-zA-Z0-9])" : - "(^|[^a-zA-Z0-9])(\(part.lowercased()))" - ), - options: [.regularExpression] - ) - .forEach { range in - let targetRange: Range = { - let term: String = String(normalizedSnippet[range]) - - // If the matched term doesn't actually match the "part" value then it means - // we've matched a term after a non-alphanumeric character so need to shift - // the range over by 1 - guard term.starts(with: part.lowercased()) else { - return (normalizedSnippet.index(after: range.lowerBound).. NSAttributedString? in - guard !authorName.isEmpty else { return nil } - - let authorPrefix: NSAttributedString = NSAttributedString( - string: "\(authorName): ", - attributes: [ .foregroundColor: textColor ] - ) - - return authorPrefix.appending(result) - } - .defaulting(to: result) - } -} - -#Preview { - GlobalSearchScreen() -} diff --git a/Session/Home/GlobalSearch/GlobalSearchViewController.swift b/Session/Home/GlobalSearch/GlobalSearchViewController.swift index fdb8ca753..6fefb61d6 100644 --- a/Session/Home/GlobalSearch/GlobalSearchViewController.swift +++ b/Session/Home/GlobalSearch/GlobalSearchViewController.swift @@ -12,12 +12,22 @@ import SignalCoreKit class GlobalSearchViewController: BaseVC, SessionUtilRespondingViewController, UITableViewDelegate, UITableViewDataSource { fileprivate typealias SectionModel = ArraySection - // MARK: - SearchSection + fileprivate struct SearchResultData { + var state: SearchResultsState + var data: [SectionModel] + } + + enum SearchResultsState: Int, Differentiable { + case none + case results + case defaultContacts + } - enum SearchSection: Int, Differentiable { - case noResults + // MARK: - SearchSection + enum SearchSection: Codable, Hashable, Differentiable { case contactsAndGroups case messages + case groupedContacts(title: String) } // MARK: - SessionUtilRespondingViewController @@ -31,18 +41,66 @@ class GlobalSearchViewController: BaseVC, SessionUtilRespondingViewController, U // MARK: - Variables - private lazy var defaultSearchResults: [SectionModel] = { - let result: SessionThreadViewModel? = Storage.shared.read { db -> SessionThreadViewModel? in + private lazy var defaultSearchResults: SearchResultData = { + let contacts: [SessionThreadViewModel] = Storage.shared.read { db -> [SessionThreadViewModel]? in try SessionThreadViewModel - .noteToSelfOnlyQuery(userPublicKey: getUserHexEncodedPublicKey(db)) - .fetchOne(db) + .defaultContactsQuery(userPublicKey: getUserHexEncodedPublicKey(db)) + .fetchAll(db) + }.defaulting(to: []) + 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 : + "Unknown" + + if groupedContacts[section] == nil { + groupedContacts[section] = SectionModel( + model: .groupedContacts(title: section), + elements: [] + ) + } + groupedContacts[section]?.elements.append(contactViewModel) } - return [ result.map { ArraySection(model: .contactsAndGroups, elements: [$0]) } ] - .compactMap { $0 } + return SearchResultData( + state: .defaultContacts, + data: groupedContacts.values.sorted { sectionModel0, sectionModel1 in + let title0 = { + switch sectionModel0.model { + case .groupedContacts(let title): return title + default: return "" + } + }() + let title1 = { + switch sectionModel1.model { + case .groupedContacts(let title): return title + default: return "" + } + }() + + if title0.count != title1.count { + return title0.count < title1.count + } + return title0 < title1 + } + ) }() + private var readConnection: Atomic = Atomic(nil) - private lazy var searchResultSet: [SectionModel] = self.defaultSearchResults + private lazy var searchResultSet: SearchResultData = defaultSearchResults private var termForCurrentSearchResultSet: String = "" private var lastSearchText: String? private var refreshTimer: Timer? @@ -215,25 +273,11 @@ class GlobalSearchViewController: BaseVC, SessionUtilRespondingViewController, U DispatchQueue.main.async { switch result { case .success(let sections): - let hasResults: Bool = ( - !searchText.isEmpty && - (sections.map { $0.elements.count }.reduce(0, +) > 0) - ) - self?.termForCurrentSearchResultSet = searchText - self?.searchResultSet = [ - (hasResults ? nil : [ - ArraySection( - model: .noResults, - elements: [ - SessionThreadViewModel(threadId: SessionThreadViewModel.invalidId) - ] - ) - ]), - (hasResults ? sections : nil) - ] - .compactMap { $0 } - .flatMap { $0 } + self?.searchResultSet = SearchResultData( + state: (sections.map { $0.elements.count }.reduce(0, +) > 0) ? .results : .none, + data: sections + ) self?.isLoading = false self?.tableView.reloadData() self?.refreshTimer = nil @@ -285,10 +329,9 @@ extension GlobalSearchViewController { public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: false) - let section: SectionModel = self.searchResultSet[indexPath.section] + let section: SectionModel = self.searchResultSet.data[indexPath.section] switch section.model { - case .noResults: break case .contactsAndGroups, .messages: show( threadId: section.elements[indexPath.row].threadId, @@ -305,6 +348,11 @@ extension GlobalSearchViewController { ) }() ) + case .groupedContacts: + show( + threadId: section.elements[indexPath.row].threadId, + threadVariant: section.elements[indexPath.row].threadVariant + ) } } @@ -340,11 +388,11 @@ extension GlobalSearchViewController { // MARK: - UITableViewDataSource public func numberOfSections(in tableView: UITableView) -> Int { - return self.searchResultSet.count + return self.searchResultSet.data.count } public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return self.searchResultSet[section].elements.count + return self.searchResultSet.data[section].elements.count } public func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { @@ -356,7 +404,7 @@ extension GlobalSearchViewController { } public func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { - guard nil != self.tableView(tableView, titleForHeaderInSection: section) else { + guard self.searchResultSet.state != .none else { return .leastNonzeroMagnitude } @@ -364,35 +412,44 @@ extension GlobalSearchViewController { } public func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { - guard let title: String = self.tableView(tableView, titleForHeaderInSection: section) else { - return UIView() - } - + let section: SectionModel = self.searchResultSet.data[section] + let titleLabel = UILabel() - titleLabel.font = .boldSystemFont(ofSize: Values.largeFontSize) - titleLabel.text = title titleLabel.themeTextColor = .textPrimary let container = UIView() container.themeBackgroundColor = .backgroundPrimary container.addSubview(titleLabel) - titleLabel.pin(.top, to: .top, of: container, withInset: Values.mediumSpacing) - titleLabel.pin(.bottom, to: .bottom, of: container, withInset: -Values.mediumSpacing) + titleLabel.pin(.top, to: .top, of: container, withInset: Values.verySmallSpacing) + titleLabel.pin(.bottom, to: .bottom, of: container, withInset: -Values.verySmallSpacing) titleLabel.pin(.left, to: .left, of: container, withInset: Values.largeSpacing) titleLabel.pin(.right, to: .right, of: container, withInset: -Values.largeSpacing) - return container - } - - public func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { - let section: SectionModel = self.searchResultSet[section] - switch section.model { - case .noResults: return nil - case .contactsAndGroups: return (section.elements.isEmpty ? nil : "SEARCH_SECTION_CONTACTS".localized()) - case .messages: return (section.elements.isEmpty ? nil : "SEARCH_SECTION_MESSAGES".localized()) + case .contactsAndGroups: + guard !section.elements.isEmpty else { return UIView() } + titleLabel.font = .boldSystemFont(ofSize: Values.largeFontSize) + titleLabel.text = "SEARCH_SECTION_CONTACTS".localized() + break + case .messages: + guard !section.elements.isEmpty else { return UIView() } + titleLabel.font = .boldSystemFont(ofSize: Values.largeFontSize) + titleLabel.text = "SEARCH_SECTION_MESSAGES".localized() + break + case .groupedContacts(let title): + guard !section.elements.isEmpty else { return UIView() } + if title.isEmpty { + titleLabel.font = .boldSystemFont(ofSize: Values.largeFontSize) + titleLabel.text = "NEW_CONVERSATION_CONTACTS_SECTION_TITLE".localized() + } else { + titleLabel.font = .systemFont(ofSize: Values.smallFontSize) + titleLabel.text = title + } + break } + + return container } public func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { @@ -400,14 +457,15 @@ extension GlobalSearchViewController { } public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let section: SectionModel = self.searchResultSet[indexPath.section] + guard self.searchResultSet.state != .none else { + let cell: EmptySearchResultCell = tableView.dequeue(type: EmptySearchResultCell.self, for: indexPath) + cell.configure(isLoading: isLoading) + return cell + } + + let section: SectionModel = self.searchResultSet.data[indexPath.section] switch section.model { - case .noResults: - let cell: EmptySearchResultCell = tableView.dequeue(type: EmptySearchResultCell.self, for: indexPath) - cell.configure(isLoading: isLoading) - return cell - case .contactsAndGroups: let cell: FullConversationCell = tableView.dequeue(type: FullConversationCell.self, for: indexPath) cell.updateForContactAndGroupSearchResult(with: section.elements[indexPath.row], searchText: self.termForCurrentSearchResultSet) @@ -417,6 +475,26 @@ extension GlobalSearchViewController { let cell: FullConversationCell = tableView.dequeue(type: FullConversationCell.self, for: indexPath) cell.updateForMessageSearchResult(with: section.elements[indexPath.row], searchText: self.termForCurrentSearchResultSet) return cell + + case .groupedContacts: + let cell: FullConversationCell = tableView.dequeue(type: FullConversationCell.self, for: indexPath) + cell.updateForDefaultContacts(with: section.elements[indexPath.row]) + return cell } } + + public func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { + guard self.searchResultSet.state == .defaultContacts else { return nil } + + return UIContextualAction.configuration( + for: UIContextualAction.generateSwipeActions( + [ .block, .delete ], + for: .trailing, + indexPath: indexPath, + tableView: tableView, + threadViewModel: self.searchResultSet.data[indexPath.section].elements[indexPath.row], + viewController: self + ) + ) + } } diff --git a/Session/Home/HomeVC.swift b/Session/Home/HomeVC.swift index a47139759..0efb80e46 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -863,8 +863,7 @@ final class HomeVC: BaseVC, SessionUtilRespondingViewController, UITableViewData if let presentedVC = self.presentedViewController { presentedVC.dismiss(animated: false, completion: nil) } -// let searchController = GlobalSearchViewController() - let searchController = SessionHostingViewController(rootView: GlobalSearchScreen(), shouldHideNavigationBar: true) + let searchController = GlobalSearchViewController() self.navigationController?.setViewControllers([ self, searchController ], animated: true) } diff --git a/Session/Shared/FullConversationCell.swift b/Session/Shared/FullConversationCell.swift index 388102be5..a1bd3760f 100644 --- a/Session/Shared/FullConversationCell.swift +++ b/Session/Shared/FullConversationCell.swift @@ -267,6 +267,32 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC // MARK: - Content // MARK: --Search Results + public func updateForDefaultContacts(with cellViewModel: SessionThreadViewModel) { + profilePictureView.update( + publicKey: cellViewModel.threadId, + threadVariant: cellViewModel.threadVariant, + customImageData: cellViewModel.openGroupProfilePictureData, + profile: cellViewModel.profile, + additionalProfile: cellViewModel.additionalProfile + ) + + isPinnedIcon.isHidden = true + unreadCountView.isHidden = true + unreadImageView.isHidden = true + hasMentionView.isHidden = true + timestampLabel.isHidden = true + timestampLabel.text = cellViewModel.lastInteractionDate.formattedForDisplay + bottomLabelStackView.isHidden = true + + ThemeManager.onThemeChange(observer: displayNameLabel) { [weak displayNameLabel] theme, _ in + guard let textColor: UIColor = theme.color(for: .textPrimary) else { return } + + displayNameLabel?.attributedText = NSMutableAttributedString( + string: cellViewModel.displayName, + attributes: [ .foregroundColor: textColor ] + ) + } + } public func updateForMessageSearchResult(with cellViewModel: SessionThreadViewModel, searchText: String) { profilePictureView.update(