Files
mobile-ios/Rosetta/Features/Chats/ChatList/ChatListSearchContent.swift

257 lines
9.4 KiB
Swift

import SwiftUI
// MARK: - Chat List Search Content
/// Search overlay for ChatListView shows recent searches or search results.
/// Matches Android's three-state pattern: skeleton empty results.
struct ChatListSearchContent: View {
let searchText: String
@ObservedObject var viewModel: ChatListViewModel
var onSelectRecent: (String) -> Void
var onOpenDialog: (ChatRoute) -> Void
var body: some View {
let trimmed = searchText.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty {
recentSearchesSection
} else {
activeSearchContent
}
}
}
// MARK: - Active Search (Three States)
private extension ChatListSearchContent {
/// Android-style: skeleton empty results only one visible at a time.
@ViewBuilder
var activeSearchContent: some View {
let localResults = viewModel.filteredDialogs
let localKeys = Set(localResults.map(\.opponentKey))
let serverOnly = viewModel.serverSearchResults.filter {
!localKeys.contains($0.publicKey)
}
let hasAnyResult = !localResults.isEmpty || !serverOnly.isEmpty
if viewModel.isServerSearching && !hasAnyResult {
SearchSkeletonView()
} else if !viewModel.isServerSearching && !hasAnyResult {
noResultsState
} else {
resultsList(localResults: localResults, serverOnly: serverOnly)
}
}
/// Lottie animation + "No results found" matches Android's empty state.
var noResultsState: some View {
VStack(spacing: 20) {
Spacer()
LottieView(
animationName: "search",
animationSpeed: 1.0
)
.frame(width: 120, height: 120)
Text("Search for users")
.font(.system(size: 17, weight: .medium))
.foregroundStyle(RosettaColors.Adaptive.text)
Text("Enter username or public key")
.font(.system(size: 14))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
/// Scrollable list of local dialogs + server results.
func resultsList(localResults: [Dialog], serverOnly: [SearchUser]) -> some View {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(localResults) { dialog in
Button {
onOpenDialog(ChatRoute(dialog: dialog))
} label: {
ChatRowView(dialog: dialog)
}
.buttonStyle(.plain)
}
ForEach(serverOnly, id: \.publicKey) { user in
serverUserRow(user)
if user.publicKey != serverOnly.last?.publicKey {
Divider()
.padding(.leading, 76)
.foregroundStyle(RosettaColors.Adaptive.divider)
}
}
Spacer().frame(height: 80)
}
}
.scrollDismissesKeyboard(.immediately)
}
}
// MARK: - Recent Searches
private extension ChatListSearchContent {
@ViewBuilder
var recentSearchesSection: some View {
if viewModel.recentSearches.isEmpty {
searchPlaceholder
} else {
ScrollView {
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) { recent in
recentRow(recent)
}
}
}
.scrollDismissesKeyboard(.immediately)
}
}
var searchPlaceholder: some View {
VStack(spacing: 20) {
Spacer()
LottieView(
animationName: "search",
animationSpeed: 1.0
)
.frame(width: 120, height: 120)
Text("Search for users")
.font(.system(size: 17, weight: .medium))
.foregroundStyle(RosettaColors.Adaptive.text)
Text("Find people by username or public key")
.font(.system(size: 14))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
func recentRow(_ user: RecentSearch) -> some View {
let isSelf = user.publicKey == SessionManager.shared.currentPublicKey
let initials = isSelf ? "S" : RosettaColors.initials(
name: user.title, publicKey: user.publicKey
)
let colorIdx = RosettaColors.avatarColorIndex(for: user.title, publicKey: user.publicKey)
return Button {
onSelectRecent(user.username.isEmpty ? user.publicKey : user.username)
} label: {
HStack(spacing: 10) {
ZStack(alignment: .topTrailing) {
AvatarView(
initials: initials, colorIndex: colorIdx,
size: 42, isSavedMessages: isSelf
)
Button {
viewModel.removeRecentSearch(publicKey: user.publicKey)
} label: {
Image(systemName: "xmark")
.font(.system(size: 9, weight: .bold))
.foregroundStyle(.white)
.frame(width: 18, height: 18)
.background(Circle().fill(RosettaColors.figmaBlue))
}
.offset(x: 4, y: -4)
}
VStack(alignment: .leading, spacing: 1) {
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 !user.lastSeenText.isEmpty {
Text(user.lastSeenText)
.font(.system(size: 13))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.lineLimit(1)
}
}
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 5)
}
.buttonStyle(.plain)
}
}
// MARK: - Server User Row
private extension ChatListSearchContent {
func serverUserRow(_ user: SearchUser) -> some View {
let isSelf = user.publicKey == SessionManager.shared.currentPublicKey
let initials = isSelf ? "S" : RosettaColors.initials(
name: user.title, publicKey: user.publicKey
)
let colorIdx = RosettaColors.avatarColorIndex(for: user.title, publicKey: user.publicKey)
return Button {
viewModel.addToRecent(user)
onOpenDialog(ChatRoute(user: user))
} label: {
HStack(spacing: 12) {
AvatarView(
initials: initials, colorIndex: colorIdx,
size: 48, isOnline: user.online == 1,
isSavedMessages: isSelf
)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 4) {
Text(isSelf ? "Saved Messages" : (
user.title.isEmpty
? String(user.publicKey.prefix(10))
: user.title
))
.font(.system(size: 16, weight: .medium))
.foregroundStyle(RosettaColors.Adaptive.text)
.lineLimit(1)
if !isSelf && (user.verified > 0 || isRosettaOfficial(user)) {
VerifiedBadge(
verified: user.verified > 0 ? user.verified : 1,
size: 16
)
}
}
Text(isSelf ? "Notes" : (
user.username.isEmpty
? "@\(String(user.publicKey.prefix(10)))..."
: "@\(user.username)"
))
.font(.system(size: 14))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.lineLimit(1)
}
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
.buttonStyle(.plain)
}
}