257 lines
9.4 KiB
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)
|
|
}
|
|
}
|