mirror of https://github.com/oxen-io/session-ios
You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
521 lines
25 KiB
Swift
521 lines
25 KiB
Swift
// 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<SearchSection, SessionThreadViewModel>
|
|
|
|
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<Database?> = 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..<searchResultSet.count, id: \.self) { sectionIndex in
|
|
let section = searchResultSet[sectionIndex]
|
|
switch section.model {
|
|
case .noResults:
|
|
Text("CONVERSATION_SEARCH_NO_RESULTS".localized())
|
|
.font(.system(size: Values.mediumFontSize))
|
|
.foregroundColor(themeColor: .textPrimary)
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: 150)
|
|
case .contactsAndGroups, .messages:
|
|
if section.elements.count > 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..<section.elements.count, id: \.self) { rowIndex in
|
|
let rowViewModel = section.elements[rowIndex]
|
|
SearchResultCell(
|
|
searchText: searchText,
|
|
searchSection: section.model,
|
|
viewModel: rowViewModel
|
|
) {
|
|
show(
|
|
threadId: rowViewModel.threadId,
|
|
threadVariant: rowViewModel.threadVariant,
|
|
focusedInteractionInfo: {
|
|
guard
|
|
let interactionId: Int64 = rowViewModel.interactionId,
|
|
let timestampMs: Int64 = rowViewModel.interactionTimestampMs
|
|
else { return nil }
|
|
|
|
return Interaction.TimestampInfo(
|
|
id: interactionId,
|
|
timestampMs: timestampMs
|
|
)
|
|
}()
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
case .defaultContacts:
|
|
Section(
|
|
header: Text("NEW_CONVERSATION_CONTACTS_SECTION_TITLE".localized())
|
|
.bold()
|
|
.font(.system(size: Values.mediumLargeFontSize))
|
|
.foregroundColor(themeColor: .textPrimary)
|
|
.padding(.horizontal, Values.mediumSpacing + Values.verySmallSpacing)
|
|
.padding(.top, Values.verySmallSpacing)
|
|
) {
|
|
ForEach(0..<defaultGroupedContacts.count, id: \.self) { groupIndex in
|
|
let sectionData = defaultGroupedContacts[groupIndex]
|
|
|
|
Section(
|
|
header: Group{
|
|
if !sectionData.sectionName.isEmpty {
|
|
Text(sectionData.sectionName)
|
|
.font(.system(size: Values.smallFontSize))
|
|
.foregroundColor(themeColor: .textPrimary)
|
|
.padding(.horizontal, Values.mediumSpacing + Values.verySmallSpacing)
|
|
.padding(.top, Values.verySmallSpacing)
|
|
}
|
|
}
|
|
) {
|
|
ForEach(0..<sectionData.contacts.count, id: \.self) { rowIndex in
|
|
let rowViewModel = sectionData.contacts[rowIndex]
|
|
SearchResultCell(
|
|
searchText: searchText,
|
|
searchSection: section.model,
|
|
viewModel: rowViewModel
|
|
) {
|
|
show(
|
|
threadId: rowViewModel.threadId,
|
|
threadVariant: rowViewModel.threadVariant,
|
|
focusedInteractionInfo: {
|
|
guard
|
|
let interactionId: Int64 = rowViewModel.interactionId,
|
|
let timestampMs: Int64 = rowViewModel.interactionTimestampMs
|
|
else { return nil }
|
|
|
|
return Interaction.TimestampInfo(
|
|
id: interactionId,
|
|
timestampMs: timestampMs
|
|
)
|
|
}()
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.backgroundColor(themeColor: .backgroundPrimary)
|
|
}
|
|
|
|
func onSearchTextChange(rawSearchText: String, force: Bool = false) {
|
|
let searchText = rawSearchText.stripped
|
|
|
|
guard searchText.count > 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<String.Index>?
|
|
|
|
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<String.Index> = {
|
|
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)..<range.upperBound)
|
|
}
|
|
|
|
return range
|
|
}()
|
|
|
|
// Store the range of the first match so we can focus it in the content displayed
|
|
if firstMatchRange == nil {
|
|
firstMatchRange = targetRange
|
|
}
|
|
|
|
let legacyRange: NSRange = NSRange(targetRange, in: normalizedSnippet)
|
|
result.addAttribute(.foregroundColor, value: textColor, range: legacyRange)
|
|
result.addAttribute(.font, value: UIFont.boldSystemFont(ofSize: fontSize), range: legacyRange)
|
|
}
|
|
}
|
|
|
|
// Now that we have generated the focused snippet add the author name as a prefix (if provided)
|
|
return authorName
|
|
.map { authorName -> 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()
|
|
}
|