328 lines
12 KiB
Swift
328 lines
12 KiB
Swift
import SwiftUI
|
|
|
|
// MARK: - SearchView
|
|
|
|
struct SearchView: View {
|
|
@Binding var isDetailPresented: Bool
|
|
@StateObject private var viewModel = SearchViewModel()
|
|
@State private var searchText = ""
|
|
@State private var navigationPath: [ChatRoute] = []
|
|
|
|
var body: some View {
|
|
NavigationStack(path: $navigationPath) {
|
|
ZStack(alignment: .bottom) {
|
|
RosettaColors.Adaptive.background
|
|
.ignoresSafeArea()
|
|
|
|
ScrollView {
|
|
VStack(spacing: 0) {
|
|
if searchText.isEmpty {
|
|
FavoriteContactsRow(navigationPath: $navigationPath)
|
|
RecentSection(
|
|
viewModel: viewModel,
|
|
navigationPath: $navigationPath
|
|
)
|
|
} else {
|
|
searchResultsContent
|
|
}
|
|
|
|
Spacer().frame(height: 120)
|
|
}
|
|
}
|
|
.scrollDismissesKeyboard(.immediately)
|
|
|
|
searchBar
|
|
}
|
|
.onChange(of: searchText) { _, newValue in
|
|
viewModel.setSearchQuery(newValue)
|
|
}
|
|
.navigationDestination(for: ChatRoute.self) { route in
|
|
ChatDetailView(
|
|
route: route,
|
|
onPresentedChange: { isPresented in
|
|
isDetailPresented = isPresented
|
|
}
|
|
)
|
|
}
|
|
.onAppear {
|
|
isDetailPresented = !navigationPath.isEmpty
|
|
}
|
|
.onChange(of: navigationPath) { _, newPath in
|
|
isDetailPresented = !newPath.isEmpty
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Search Bar (bottom, Figma style)
|
|
|
|
private extension SearchView {
|
|
var searchBar: some View {
|
|
HStack(spacing: 12) {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "magnifyingglass")
|
|
.font(.system(size: 17, weight: .medium))
|
|
.foregroundStyle(RosettaColors.adaptive(
|
|
light: Color(hex: 0x8C8C8C),
|
|
dark: Color(hex: 0x8E8E93)
|
|
))
|
|
|
|
TextField("Search", text: $searchText)
|
|
.font(.system(size: 17, weight: .medium))
|
|
.foregroundStyle(RosettaColors.Adaptive.text)
|
|
.submitLabel(.search)
|
|
.autocorrectionDisabled()
|
|
.textInputAutocapitalization(.never)
|
|
|
|
if !searchText.isEmpty {
|
|
Button {
|
|
searchText = ""
|
|
viewModel.clearSearch()
|
|
} label: {
|
|
Image(systemName: "xmark.circle.fill")
|
|
.font(.system(size: 16))
|
|
.foregroundStyle(RosettaColors.adaptive(
|
|
light: Color(hex: 0x8C8C8C),
|
|
dark: Color(hex: 0x8E8E93)
|
|
))
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 11)
|
|
.frame(height: 42)
|
|
.background {
|
|
Capsule()
|
|
.fill(RosettaColors.adaptive(
|
|
light: Color(hex: 0xF7F7F7),
|
|
dark: Color(hex: 0x2A2A2A)
|
|
))
|
|
}
|
|
.applyGlassSearchBar()
|
|
|
|
Button {
|
|
// TODO: Compose new chat
|
|
} label: {
|
|
Image(systemName: "square.and.pencil")
|
|
.font(.system(size: 17, weight: .medium))
|
|
.foregroundStyle(RosettaColors.adaptive(
|
|
light: Color(hex: 0x404040),
|
|
dark: Color(hex: 0x8E8E93)
|
|
))
|
|
.frame(width: 42, height: 42)
|
|
.background {
|
|
Circle()
|
|
.fill(RosettaColors.adaptive(
|
|
light: Color(hex: 0xF7F7F7),
|
|
dark: Color(hex: 0x2A2A2A)
|
|
))
|
|
}
|
|
.applyGlassSearchBar()
|
|
}
|
|
.accessibilityLabel("New chat")
|
|
}
|
|
.padding(.horizontal, 28)
|
|
.padding(.bottom, 8)
|
|
.background {
|
|
RosettaColors.Adaptive.background
|
|
.opacity(0.95)
|
|
.ignoresSafeArea()
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
// MARK: - Favorite Contacts (isolated — reads DialogRepository in own scope)
|
|
|
|
/// Isolated child view so that `DialogRepository.shared.sortedDialogs` observation
|
|
/// does NOT propagate to `SearchView`'s NavigationStack.
|
|
private struct FavoriteContactsRow: View {
|
|
@Binding var navigationPath: [ChatRoute]
|
|
|
|
var body: some View {
|
|
let dialogs = DialogRepository.shared.sortedDialogs.prefix(10)
|
|
if !dialogs.isEmpty {
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 4) {
|
|
ForEach(Array(dialogs), id: \.id) { dialog in
|
|
Button {
|
|
navigationPath.append(ChatRoute(dialog: dialog))
|
|
} label: {
|
|
VStack(spacing: 4) {
|
|
AvatarView(
|
|
initials: dialog.initials,
|
|
colorIndex: dialog.avatarColorIndex,
|
|
size: 62,
|
|
isOnline: dialog.isOnline,
|
|
isSavedMessages: dialog.isSavedMessages
|
|
)
|
|
|
|
Text(dialog.isSavedMessages ? "Saved" : dialog.opponentTitle.components(separatedBy: " ").first ?? "")
|
|
.font(.system(size: 11))
|
|
.tracking(0.06)
|
|
.foregroundStyle(RosettaColors.Adaptive.text)
|
|
.lineLimit(1)
|
|
.frame(width: 78)
|
|
}
|
|
.frame(width: 78)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
.padding(.horizontal, 2)
|
|
}
|
|
.padding(.top, 12)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Recent Section (isolated — reads SessionManager in own scope)
|
|
|
|
/// Isolated child view so that `SessionManager.shared.currentPublicKey` observation
|
|
/// does NOT propagate to `SearchView`'s NavigationStack.
|
|
private struct RecentSection: View {
|
|
@ObservedObject var viewModel: SearchViewModel
|
|
@Binding var navigationPath: [ChatRoute]
|
|
var body: some View {
|
|
if viewModel.recentSearches.isEmpty {
|
|
emptyState
|
|
} else {
|
|
VStack(spacing: 0) {
|
|
HStack {
|
|
Text("RECENT")
|
|
.font(.system(size: 13))
|
|
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
|
|
|
Spacer()
|
|
|
|
Button {
|
|
viewModel.clearRecentSearches()
|
|
} label: {
|
|
Text("Clear")
|
|
.font(.system(size: 13))
|
|
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.top, 8)
|
|
.padding(.bottom, 6)
|
|
|
|
ForEach(viewModel.recentSearches, id: \.publicKey) { user in
|
|
recentRow(user)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var emptyState: some View {
|
|
VStack(spacing: 16) {
|
|
LottieView(
|
|
animationName: "search",
|
|
animationSpeed: 1.0
|
|
)
|
|
.frame(width: 120, height: 120)
|
|
.padding(.top, 100)
|
|
|
|
Text("Search for users")
|
|
.font(.system(size: 17, weight: .semibold))
|
|
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
|
|
|
Text("Find people by username or public key")
|
|
.font(.system(size: 15))
|
|
.foregroundStyle(RosettaColors.Adaptive.textTertiary)
|
|
.multilineTextAlignment(.center)
|
|
.padding(.horizontal, 40)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
|
|
private func recentRow(_ user: RecentSearch) -> some View {
|
|
let currentPK = SessionManager.shared.currentPublicKey
|
|
let isSelf = user.publicKey == currentPK
|
|
let initials = isSelf ? "S" : RosettaColors.initials(name: user.title, publicKey: user.publicKey)
|
|
let colorIdx = RosettaColors.avatarColorIndex(for: user.title, publicKey: user.publicKey)
|
|
let effectiveVerified = Self.effectiveVerifiedLevel(
|
|
verified: user.verified, title: user.title,
|
|
username: user.username, publicKey: user.publicKey
|
|
)
|
|
|
|
return Button {
|
|
navigationPath.append(ChatRoute(recent: user))
|
|
} label: {
|
|
HStack(spacing: 12) {
|
|
AvatarView(
|
|
initials: initials,
|
|
colorIndex: colorIdx,
|
|
size: 42,
|
|
isSavedMessages: isSelf
|
|
)
|
|
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
HStack(spacing: 4) {
|
|
Text(isSelf ? "Saved Messages" : (user.title.isEmpty ? String(user.publicKey.prefix(16)) + "..." : user.title))
|
|
.font(.system(size: 17, weight: .medium))
|
|
.foregroundStyle(RosettaColors.Adaptive.text)
|
|
.lineLimit(1)
|
|
if !isSelf && effectiveVerified > 0 {
|
|
VerifiedBadge(
|
|
verified: effectiveVerified,
|
|
size: 14
|
|
)
|
|
}
|
|
}
|
|
// Desktop parity: search subtitle shows @username, not online/offline.
|
|
if !isSelf {
|
|
Text(user.username.isEmpty
|
|
? "@\(String(user.publicKey.prefix(10)))..."
|
|
: "@\(user.username)"
|
|
)
|
|
.font(.system(size: 13))
|
|
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 5)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
/// Desktop parity: compute effective verified level — server value + client heuristic.
|
|
private static func effectiveVerifiedLevel(
|
|
verified: Int, title: String, username: String, publicKey: String
|
|
) -> Int {
|
|
if verified > 0 { return verified }
|
|
if title.caseInsensitiveCompare("Rosetta") == .orderedSame { return 1 }
|
|
if username.caseInsensitiveCompare("rosetta") == .orderedSame { return 1 }
|
|
if title.caseInsensitiveCompare("freddy") == .orderedSame { return 1 }
|
|
if username.caseInsensitiveCompare("freddy") == .orderedSame { return 1 }
|
|
if SystemAccounts.isSystemAccount(publicKey) { return 1 }
|
|
return 0
|
|
}
|
|
}
|
|
|
|
// MARK: - Search Results Content
|
|
|
|
private extension SearchView {
|
|
var searchResultsContent: some View {
|
|
SearchResultsSection(
|
|
isSearching: viewModel.isSearching,
|
|
searchResults: viewModel.searchResults,
|
|
currentPublicKey: SessionManager.shared.currentPublicKey,
|
|
onSelectUser: { user in
|
|
viewModel.addToRecent(user)
|
|
navigationPath.append(ChatRoute(user: user))
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Preview
|
|
|
|
#Preview {
|
|
SearchView(isDetailPresented: .constant(false))
|
|
}
|