From a4286212b42bd447d47a788bc7a091e73a8387df Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Mon, 5 Feb 2024 13:10:57 +1100 Subject: [PATCH] imp: default contact list of global search screen --- Session.xcodeproj/project.pbxproj | 4 + .../GlobalSearch/GlobalSearchScreen.swift | 169 +++++++++++++----- .../SessionThreadViewModel.swift | 44 +++++ .../SwiftUI/CompatibleScrollingVStack.swift | 26 +++ .../Components/SwiftUI/SessionTextField.swift | 2 +- .../Utilities/SwiftUI+Utilities.swift | 12 +- 6 files changed, 215 insertions(+), 42 deletions(-) create mode 100644 SessionUIKit/Components/SwiftUI/CompatibleScrollingVStack.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 2aebde9e9..9f111986f 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -169,6 +169,7 @@ 7BFD1A972747689000FB91B9 /* Session-Turn-Server in Resources */ = {isa = PBXBuildFile; fileRef = 7BFD1A962747689000FB91B9 /* Session-Turn-Server */; }; 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 */; }; 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 */; }; @@ -1312,6 +1313,7 @@ 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; }; 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 = ""; }; 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 = ""; }; @@ -2501,6 +2503,7 @@ 7B87EF432A8DA720002A0E8F /* SessionTextField.swift */, 7BF570D22A9C1F9300DB013E /* Toast.swift */, 942C9CA12B67769000B5153A /* SessionSearchBar.swift */, + 943C6D752B705B7D004ACE64 /* CompatibleScrollingVStack.swift */, ); path = SwiftUI; sourceTree = ""; @@ -5683,6 +5686,7 @@ FD0B77B029B69A65009169BA /* TopBannerController.swift in Sources */, FD37E9F628A5F106003AE748 /* Configuration.swift in Sources */, 7BF8D1FB2A70AF57005F1D6E /* SwiftUI+Theme.swift in Sources */, + 943C6D762B705B7D004ACE64 /* CompatibleScrollingVStack.swift in Sources */, FDBB25E72988BBBE00F1508E /* UIContextualAction+Theming.swift in Sources */, C331FFE02558FB0000070591 /* SearchBar.swift in Sources */, FD16AB5B2A1DD7CA0083D849 /* PlaceholderIcon.swift in Sources */, diff --git a/Session/Home/GlobalSearch/GlobalSearchScreen.swift b/Session/Home/GlobalSearch/GlobalSearchScreen.swift index 75c670387..4e4382457 100644 --- a/Session/Home/GlobalSearch/GlobalSearchScreen.swift +++ b/Session/Home/GlobalSearch/GlobalSearchScreen.swift @@ -18,6 +18,11 @@ enum SearchSection: Int, Differentiable { struct GlobalSearchScreen: View { fileprivate typealias SectionModel = ArraySection + + struct SectionData { + var sectionName: String + var contacts: [SessionThreadViewModel] + } @EnvironmentObject var host: HostWrapper @@ -29,16 +34,54 @@ struct GlobalSearchScreen: View { @State private var isLoading = false fileprivate static var defaultSearchResults: [SectionModel] = { - let result: SessionThreadViewModel? = Storage.shared.read { db -> SessionThreadViewModel? in + let result: [SessionThreadViewModel]? = Storage.shared.read { db -> [SessionThreadViewModel]? in try SessionThreadViewModel - .noteToSelfOnlyQuery(userPublicKey: getUserHexEncodedPublicKey(db)) - .fetchOne(db) + .defaultContactsQuery(userPublicKey: getUserHexEncodedPublicKey(db)) + .fetchAll(db) } - return [ result.map { ArraySection(model: .defaultContacts, elements: [$0]) } ] + 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( @@ -50,19 +93,21 @@ struct GlobalSearchScreen: View { } ) - List{ + CompatibleScrollingVStack( + alignment: .leading + ) { ForEach(0.. 0 { @@ -71,40 +116,80 @@ struct GlobalSearchScreen: View { .bold() .font(.system(size: Values.mediumLargeFontSize)) .foregroundColor(themeColor: .textPrimary) + .padding(.horizontal, Values.mediumSpacing + Values.verySmallSpacing) + .padding(.top, Values.verySmallSpacing) ) { - ForEach(0.. AdaptedFetchRequest> { + let thread: TypedTableAlias = TypedTableAlias() + let contactProfile: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .contactProfile) + + /// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before + /// the `contactProfile` entry below otherwise the query will fail to parse and might throw + let numColumnsBeforeProfiles: Int = 8 + let request: SQLRequest = """ + SELECT + 100 AS \(Column.rank), + + \(thread[.rowId]) AS \(ViewModel.Columns.rowId), + \(thread[.id]) AS \(ViewModel.Columns.threadId), + \(thread[.variant]) AS \(ViewModel.Columns.threadVariant), + \(thread[.creationDateTimestamp]) AS \(ViewModel.Columns.threadCreationDateTimestamp), + '' AS \(ViewModel.Columns.threadMemberNames), + + (\(SQL("\(thread[.id]) = \(userPublicKey)"))) AS \(ViewModel.Columns.threadIsNoteToSelf), + IFNULL(\(thread[.pinnedPriority]), 0) AS \(ViewModel.Columns.threadPinnedPriority), + + \(contactProfile.allColumns), + + \(SQL("\(userPublicKey)")) AS \(ViewModel.Columns.currentUserPublicKey) + + FROM \(SessionThread.self) + JOIN \(contactProfile) ON \(contactProfile[.id]) = \(thread[.id]) + + WHERE \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) + """ + + // Add adapters which will group the various 'Profile' columns so they can be decoded + // as instances of 'Profile' types + return request.adapted { db in + let adapters = try splittingRowAdapters(columnCounts: [ + numColumnsBeforeProfiles, + Profile.numberOfSelectedColumns(db) + ]) + + return ScopeAdapter.with(ViewModel.self, [ + .contactProfile: adapters[1] + ]) + } + } + /// This method returns only the 'Note to Self' thread in the structure of a search result conversation static func noteToSelfOnlyQuery(userPublicKey: String) -> AdaptedFetchRequest> { let thread: TypedTableAlias = TypedTableAlias() diff --git a/SessionUIKit/Components/SwiftUI/CompatibleScrollingVStack.swift b/SessionUIKit/Components/SwiftUI/CompatibleScrollingVStack.swift new file mode 100644 index 000000000..81660ce1e --- /dev/null +++ b/SessionUIKit/Components/SwiftUI/CompatibleScrollingVStack.swift @@ -0,0 +1,26 @@ +// Copyright © 2024 Rangeproof Pty Ltd. All rights reserved. + +import SwiftUI + +public struct CompatibleScrollingVStack : View where Content : View { + let alignment: HorizontalAlignment + let spacing: CGFloat? + let content: () -> Content + + public init(alignment: HorizontalAlignment = .center, spacing: CGFloat? = nil, + @ViewBuilder content: @escaping () -> Content) { + self.alignment = alignment + self.spacing = spacing + self.content = content + } + + public var body: some View { + ScrollView { + if #available(iOS 14, *) { + LazyVStack(alignment: alignment, spacing: spacing, pinnedViews: [], content:content) + } else { + VStack(alignment: alignment, spacing: spacing, content:content) + } + } + } +} diff --git a/SessionUIKit/Components/SwiftUI/SessionTextField.swift b/SessionUIKit/Components/SwiftUI/SessionTextField.swift index e285fb257..5db887d41 100644 --- a/SessionUIKit/Components/SwiftUI/SessionTextField.swift +++ b/SessionUIKit/Components/SwiftUI/SessionTextField.swift @@ -68,7 +68,7 @@ public struct SessionTextField: View where ExplanationView: Vie ) .font(.system(size: Values.smallFontSize)) .foregroundColor(themeColor: textThemeColor) - .transparentScrolling() + .textViewTransparentScrolling() .frame(maxHeight: self.height) .padding(.all, -4) diff --git a/SessionUIKit/Utilities/SwiftUI+Utilities.swift b/SessionUIKit/Utilities/SwiftUI+Utilities.swift index c209f263b..c6ba83c1c 100644 --- a/SessionUIKit/Utilities/SwiftUI+Utilities.swift +++ b/SessionUIKit/Utilities/SwiftUI+Utilities.swift @@ -103,7 +103,7 @@ extension View { self.modifier(ToastModifier(message: message)) } - public func transparentScrolling() -> some View { + public func textViewTransparentScrolling() -> some View { if #available(iOS 16.0, *) { return scrollContentBackground(.hidden) } else { @@ -113,6 +113,16 @@ extension View { } } + public func transparentListBackground() -> some View { + if #available(iOS 16.0, *) { + return scrollContentBackground(.hidden) + } else { + return onAppear { + UITableView.appearance().backgroundColor = .clear + } + } + } + public func hideListRowSeparator() -> some View { if #available(iOS 15.0, *) { return listRowSeparator(.hidden)