implement contacts and global search tweaks

pull/891/head
Ryan ZHAO 1 year ago
parent 673170360d
commit 86ee0317ef

@ -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 = "<group>"; };
7B02DF442A16F47B00ADCFD2 /* build_libSession_util.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = build_libSession_util.sh; sourceTree = "<group>"; };
7B0EFDED274F598600FFAAE7 /* TimestampUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimestampUtils.swift; sourceTree = "<group>"; };
7B0EFDEF275084AA00FFAAE7 /* CallMessageCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallMessageCell.swift; sourceTree = "<group>"; };
7B0EFDF3275490EA00FFAAE7 /* ringing.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = ringing.mp3; sourceTree = "<group>"; };
@ -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 = "<group>"; };
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 = "<group>"; };
942C9CA12B67769000B5153A /* SessionSearchBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionSearchBar.swift; sourceTree = "<group>"; };
942C9CA32B6868B800B5153A /* GlobalSearchScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalSearchScreen.swift; sourceTree = "<group>"; };
943C6D752B705B7D004ACE64 /* CompatibleScrollingVStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompatibleScrollingVStack.swift; sourceTree = "<group>"; };
943C6D812B75E061004ACE64 /* Message+DisappearingMessages.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Message+DisappearingMessages.swift"; sourceTree = "<group>"; };
946B34462B5DF0B7004CB4A3 /* QRCodeScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeScreen.swift; sourceTree = "<group>"; };
946B34482B5E04BB004CB4A3 /* CustomTopTabBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTopTabBar.swift; sourceTree = "<group>"; };
946B344A2B5E08F3004CB4A3 /* ScanQRCodeScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanQRCodeScreen.swift; sourceTree = "<group>"; };
946B344C2B5F67B4004CB4A3 /* StartConversationScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartConversationScreen.swift; sourceTree = "<group>"; };
946B344E2B61D80B004CB4A3 /* InviteAFriendScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InviteAFriendScreen.swift; sourceTree = "<group>"; };
946B34502B61D818004CB4A3 /* NewMessageScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewMessageScreen.swift; sourceTree = "<group>"; };
9422EE2A2B8C3A97004C740D /* String+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Utilities.swift"; sourceTree = "<group>"; };
943C6D812B75E061004ACE64 /* Message+DisappearingMessages.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Message+DisappearingMessages.swift"; sourceTree = "<group>"; };
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 = "<group>";
@ -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 */,
);

@ -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<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()
}

@ -12,12 +12,22 @@ import SignalCoreKit
class GlobalSearchViewController: BaseVC, SessionUtilRespondingViewController, UITableViewDelegate, UITableViewDataSource {
fileprivate typealias SectionModel = ArraySection<SearchSection, SessionThreadViewModel>
// 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<Database?> = 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
)
)
}
}

@ -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)
}

@ -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(

Loading…
Cancel
Save