diff --git a/Session/Home/GlobalSearch/GlobalSearchScreen.swift b/Session/Home/GlobalSearch/GlobalSearchScreen.swift index b72c04bbd..d0e0318cf 100644 --- a/Session/Home/GlobalSearch/GlobalSearchScreen.swift +++ b/Session/Home/GlobalSearch/GlobalSearchScreen.swift @@ -9,15 +9,16 @@ 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 - enum SearchSection: Int, Differentiable { - case noResults - case contacts - case messages - } - @EnvironmentObject var host: HostWrapper @State private var searchText: String = "" @@ -34,7 +35,7 @@ struct GlobalSearchScreen: View { .fetchOne(db) } - return [ result.map { ArraySection(model: .contacts, elements: [$0]) } ] + return [ result.map { ArraySection(model: .defaultContacts, elements: [$0]) } ] .compactMap { $0 } }() @@ -54,20 +55,49 @@ struct GlobalSearchScreen: View { let section = searchResultSet[sectionIndex] let sectionTitle: String = { switch section.model { - case .noResults: return "" - case .contacts: return (section.elements.isEmpty ? "" : "NEW_CONVERSATION_CONTACTS_SECTION_TITLE".localized()) - case .messages:return (section.elements.isEmpty ? "" : "SEARCH_SECTION_MESSAGES".localized()) + case .noResults: + return "" + case .contactsAndGroups: + return (section.elements.isEmpty ? "" : "CONVERSATION_SETTINGS_TITLE".localized()) + case .messages: + return (section.elements.isEmpty ? "" : "SEARCH_SECTION_MESSAGES".localized()) + case .defaultContacts: + return "NEW_CONVERSATION_CONTACTS_SECTION_TITLE".localized() } }() - Section( - header: Text(sectionTitle) - .bold() - .font(.system(size: Values.mediumLargeFontSize)) - .foregroundColor(themeColor: .textPrimary) - ) { - ForEach(0.. 0 { + Section( + header: Text(sectionTitle) + .bold() + .font(.system(size: Values.mediumLargeFontSize)) + .foregroundColor(themeColor: .textPrimary) + ) { + ForEach(0.. Void var body: some View { - 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 - ) - - VStack( - alignment: .leading, - spacing: Values.verySmallSpacing + Button { + action() + } label: { + HStack( + alignment: .center, + spacing: Values.mediumSpacing ) { - Text(viewModel.displayName) - .bold() - .font(.system(size: Values.mediumFontSize)) - .foregroundColor(themeColor: .textPrimary) + 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 + ) + + VStack( + alignment: .leading, + spacing: Values.verySmallSpacing + ) { + Text(viewModel.displayName) + .bold() + .font(.system(size: Values.mediumFontSize)) + .foregroundColor(themeColor: .textPrimary) + + 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) + } + } + } } } } + + 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: (CurrentAppContext().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 { diff --git a/SessionUIKit/Utilities/SwiftUI+Utilities.swift b/SessionUIKit/Utilities/SwiftUI+Utilities.swift index de424ccf2..bc075f746 100644 --- a/SessionUIKit/Utilities/SwiftUI+Utilities.swift +++ b/SessionUIKit/Utilities/SwiftUI+Utilities.swift @@ -112,6 +112,20 @@ extension View { } } } + + public func hideListRowSeparator() -> some View { + if #available(iOS 15.0, *) { + return listRowSeparator(.hidden) + } else { + return onAppear { + UITableView.appearance().separatorStyle = .none + } + } + } + +// public func swipeActions() -> some View { +// +// } } extension Binding {