feat: Introduce system accounts and verification badges

- Added SystemAccounts enum to manage system account keys and titles.
- Refactored Dialog model to replace isVerified with verified level.
- Implemented effective verification logic for UI display in Dialog.
- Updated DialogRepository to handle user verification levels.
- Enhanced ProtocolManager and SessionManager to log user info with verification.
- Modified AuthCoordinator to support back navigation to unlock screen.
- Improved UnlockView and WelcomeView with new account creation flow.
- Added VerifiedBadge component to visually represent account verification levels.
- Updated ChatListView and SearchView to display verification badges for users.
- Cleaned up debug print statements across various components.
This commit is contained in:
2026-02-26 01:57:15 +05:00
parent 99a35302fa
commit 5f163af1d8
33 changed files with 1903 additions and 1466 deletions

View File

@@ -0,0 +1,67 @@
import SwiftUI
import Lottie
// MARK: - ChatEmptyStateView
struct ChatEmptyStateView: View {
let searchText: String
var body: some View {
VStack(spacing: 0) {
if searchText.isEmpty {
noConversationsContent
} else {
noSearchResultsContent
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.offset(y: -40)
}
}
// MARK: - Content Variants
private extension ChatEmptyStateView {
var noConversationsContent: some View {
Group {
LottieView(animationName: "letter", loopMode: .playOnce, animationSpeed: 1.0)
.frame(width: 150, height: 150)
Spacer().frame(height: 24)
Text("No conversations yet")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.multilineTextAlignment(.center)
Spacer().frame(height: 8)
Text("Start a new conversation to get started")
.font(.system(size: 15))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
}
}
var noSearchResultsContent: some View {
Group {
Image(systemName: "magnifyingglass")
.font(.system(size: 52))
.foregroundStyle(RosettaColors.Adaptive.textSecondary.opacity(0.5))
Spacer().frame(height: 16)
Text("No results for \"\(searchText)\"")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.multilineTextAlignment(.center)
}
}
}
// MARK: - Preview
#Preview {
ChatEmptyStateView(searchText: "")
}

View File

@@ -0,0 +1,244 @@
import Lottie
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 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", loopMode: .playOnce, 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
ChatRowView(dialog: dialog)
}
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(.interactively)
}
}
// 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(.interactively)
}
}
var searchPlaceholder: some View {
VStack(spacing: 20) {
Spacer()
LottieView(animationName: "search", loopMode: .loop, 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.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.publicKey)
return Button {
viewModel.addToRecent(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)
}
}

View File

@@ -3,9 +3,9 @@ import SwiftUI
// MARK: - ChatListView
struct ChatListView: View {
@State private var viewModel = ChatListViewModel()
@Binding var isSearchActive: Bool
@StateObject private var viewModel = ChatListViewModel()
@State private var searchText = ""
@State private var isSearchPresented = false
var body: some View {
NavigationStack {
@@ -13,7 +13,15 @@ struct ChatListView: View {
RosettaColors.Adaptive.background
.ignoresSafeArea()
chatContent
if isSearchActive {
ChatListSearchContent(
searchText: searchText,
viewModel: viewModel,
onSelectRecent: { searchText = $0 }
)
} else {
normalContent
}
}
.navigationBarTitleDisplayMode(.inline)
.toolbar { toolbarContent }
@@ -21,7 +29,7 @@ struct ChatListView: View {
.applyGlassNavBar()
.searchable(
text: $searchText,
isPresented: $isSearchPresented,
isPresented: $isSearchActive,
placement: .navigationBarDrawer(displayMode: .always),
prompt: "Search"
)
@@ -33,29 +41,19 @@ struct ChatListView: View {
}
}
// MARK: - Glass Nav Bar Modifier
private struct GlassNavBarModifier: ViewModifier {
func body(content: Content) -> some View {
if #available(iOS 26, *) {
content
} else {
content
.toolbarBackground(.ultraThinMaterial, for: .navigationBar)
}
}
}
private extension View {
func applyGlassNavBar() -> some View {
modifier(GlassNavBarModifier())
}
}
// MARK: - Chat Content
// MARK: - Normal Content
private extension ChatListView {
var chatContent: some View {
@ViewBuilder
var normalContent: some View {
if viewModel.filteredDialogs.isEmpty && !viewModel.isLoading {
ChatEmptyStateView(searchText: "")
} else {
dialogList
}
}
var dialogList: some View {
List {
if viewModel.isLoading {
ForEach(0..<8, id: \.self) { _ in
@@ -64,29 +62,19 @@ private extension ChatListView {
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
}
} else if viewModel.filteredDialogs.isEmpty && !viewModel.showServerResults {
emptyState
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
} else {
// Local dialog results
if !viewModel.pinnedDialogs.isEmpty {
pinnedSection
ForEach(viewModel.pinnedDialogs) { dialog in
chatRow(dialog)
.listRowBackground(RosettaColors.Adaptive.backgroundSecondary)
}
}
ForEach(viewModel.unpinnedDialogs) { dialog in
chatRow(dialog)
}
// Server search results
if viewModel.showServerResults {
serverSearchSection
}
}
Color.clear
.frame(height: 80)
Color.clear.frame(height: 80)
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
@@ -95,17 +83,6 @@ private extension ChatListView {
.scrollContentBackground(.hidden)
.scrollDismissesKeyboard(.interactively)
}
}
// MARK: - Pinned Section
private extension ChatListView {
var pinnedSection: some View {
ForEach(viewModel.pinnedDialogs) { dialog in
chatRow(dialog)
.listRowBackground(RosettaColors.Adaptive.backgroundSecondary)
}
}
func chatRow(_ dialog: Dialog) -> some View {
ChatRowView(dialog: dialog)
@@ -119,7 +96,6 @@ private extension ChatListView {
} label: {
Label("Delete", systemImage: "trash")
}
Button {
viewModel.toggleMute(dialog)
} label: {
@@ -137,7 +113,6 @@ private extension ChatListView {
Label("Read", systemImage: "envelope.open")
}
.tint(RosettaColors.figmaBlue)
Button {
viewModel.togglePin(dialog)
} label: {
@@ -154,9 +129,7 @@ private extension ChatListView {
@ToolbarContentBuilder
var toolbarContent: some ToolbarContent {
ToolbarItem(placement: .navigationBarLeading) {
Button {
// TODO: Edit mode
} label: {
Button { } label: {
Text("Edit")
.font(.system(size: 17, weight: .medium))
.foregroundStyle(RosettaColors.Adaptive.text)
@@ -166,218 +139,41 @@ private extension ChatListView {
ToolbarItem(placement: .principal) {
HStack(spacing: 4) {
storiesAvatars
Text("Chats")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.text)
Image(systemName: "checkmark.seal.fill")
.font(.system(size: 14))
.foregroundStyle(RosettaColors.figmaBlue)
}
}
ToolbarItemGroup(placement: .navigationBarTrailing) {
Button {
// TODO: Camera
} label: {
Image(systemName: "camera")
.font(.system(size: 18))
.foregroundStyle(RosettaColors.Adaptive.text)
HStack(spacing: 8) {
Button { } label: {
Image(systemName: "camera")
.font(.system(size: 16, weight: .regular))
.foregroundStyle(RosettaColors.Adaptive.text)
}
.accessibilityLabel("Camera")
Button { } label: {
Image(systemName: "square.and.pencil")
.font(.system(size: 17, weight: .regular))
.foregroundStyle(RosettaColors.Adaptive.text)
}
.padding(.bottom, 2)
.accessibilityLabel("New chat")
}
.accessibilityLabel("Camera")
Button {
// TODO: Compose new message
} label: {
Image(systemName: "square.and.pencil")
.font(.system(size: 18))
.foregroundStyle(RosettaColors.Adaptive.text)
}
.accessibilityLabel("New chat")
}
}
@ViewBuilder
private var storiesAvatars: some View {
let pk = AccountManager.shared.currentAccount?.publicKey ?? ""
let initials = RosettaColors.initials(name: SessionManager.shared.displayName, publicKey: pk)
let colorIdx = RosettaColors.avatarColorIndex(for: pk)
ZStack {
AvatarView(initials: initials, colorIndex: colorIdx, size: 28)
}
}
}
// MARK: - Server Search Results
private extension ChatListView {
@ViewBuilder
var serverSearchSection: some View {
if viewModel.isServerSearching {
HStack {
Spacer()
ProgressView()
.tint(RosettaColors.Adaptive.textSecondary)
Text("Searching users...")
.font(.system(size: 15))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
Spacer()
}
.padding(.vertical, 16)
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
} else if !viewModel.serverSearchResults.isEmpty {
Section {
ForEach(viewModel.serverSearchResults, id: \.publicKey) { user in
serverSearchRow(user)
}
} header: {
Text("GLOBAL SEARCH")
.font(.system(size: 13))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
}
} else if viewModel.filteredDialogs.isEmpty {
emptyState
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
}
}
func serverSearchRow(_ 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.publicKey)
return Button {
// TODO: Navigate to ChatDetailView
} label: {
HStack(spacing: 12) {
AvatarView(
initials: initials,
colorIndex: colorIdx,
size: 52,
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(16)) + "..." : user.title))
.font(.system(size: 17, weight: .medium))
.foregroundStyle(RosettaColors.Adaptive.text)
.lineLimit(1)
if user.verified > 0 {
Image(systemName: "checkmark.seal.fill")
.font(.system(size: 12))
.foregroundStyle(RosettaColors.figmaBlue)
}
}
if !user.username.isEmpty {
Text("@\(user.username)")
.font(.system(size: 14))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.lineLimit(1)
}
}
Spacer()
if user.online == 1 {
Circle()
.fill(RosettaColors.online)
.frame(width: 8, height: 8)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 6)
}
.buttonStyle(.plain)
.listRowInsets(EdgeInsets())
}
}
// MARK: - Empty State
private extension ChatListView {
var emptyState: some View {
VStack(spacing: 16) {
Image(systemName: searchText.isEmpty ? "bubble.left.and.bubble.right" : "magnifyingglass")
.font(.system(size: 52))
.foregroundStyle(RosettaColors.Adaptive.textSecondary.opacity(0.5))
.padding(.top, 80)
Text(searchText.isEmpty ? "No chats yet" : "No results for \"\(searchText)\"")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
if searchText.isEmpty {
Text("Start a conversation by tapping the search tab")
.font(.system(size: 15))
.foregroundStyle(RosettaColors.Adaptive.textTertiary)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
}
}
.frame(maxWidth: .infinity)
}
}
// MARK: - Shimmer Row
private struct ChatRowShimmerView: View {
@State private var phase: CGFloat = 0
@Environment(\.colorScheme) private var colorScheme
var body: some View {
HStack(spacing: 12) {
Circle()
.fill(shimmerGradient)
.frame(width: 62, height: 62)
VStack(alignment: .leading, spacing: 8) {
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(width: 140, height: 14)
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(width: 200, height: 12)
}
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.onAppear {
withAnimation(.linear(duration: 1.4).repeatForever(autoreverses: false)) {
phase = 1
}
}
}
var shimmerGradient: LinearGradient {
let baseOpacity = colorScheme == .dark ? 0.06 : 0.08
let peakOpacity = colorScheme == .dark ? 0.12 : 0.16
return LinearGradient(
colors: [
Color.gray.opacity(baseOpacity),
Color.gray.opacity(peakOpacity),
Color.gray.opacity(baseOpacity),
],
startPoint: UnitPoint(x: phase - 0.4, y: 0),
endPoint: UnitPoint(x: phase + 0.4, y: 0)
let initials = RosettaColors.initials(
name: SessionManager.shared.displayName, publicKey: pk
)
let colorIdx = RosettaColors.avatarColorIndex(for: pk)
ZStack { AvatarView(initials: initials, colorIndex: colorIdx, size: 28) }
}
}
// MARK: - Preview
#Preview { ChatListView(isSearchActive: .constant(false)) }
#Preview {
ChatListView()
}

View File

@@ -1,23 +1,34 @@
import Combine
import Foundation
import os
// MARK: - ChatListViewModel
@Observable
@MainActor
final class ChatListViewModel {
final class ChatListViewModel: ObservableObject {
private static let logger = Logger(subsystem: "com.rosetta.messenger", category: "ChatListVM")
// MARK: - State
private(set) var isLoading = false
private(set) var searchQuery = ""
@Published var isLoading = false
@Published var searchQuery = ""
@Published var serverSearchResults: [SearchUser] = []
@Published var isServerSearching = false
@Published var recentSearches: [RecentSearch] = []
// Server search state
private(set) var serverSearchResults: [SearchUser] = []
private(set) var isServerSearching = false
private var searchTask: Task<Void, Never>?
private var lastSearchedText = ""
private static let maxRecent = 20
private var recentKey: String {
"rosetta_recent_searches_\(SessionManager.shared.currentPublicKey)"
}
// MARK: - Init
init() {
loadRecentSearches()
setupSearchCallback()
}
@@ -25,7 +36,6 @@ final class ChatListViewModel {
var filteredDialogs: [Dialog] {
var result = DialogRepository.shared.sortedDialogs
let query = searchQuery.trimmingCharacters(in: .whitespaces).lowercased()
if !query.isEmpty {
result = result.filter {
@@ -34,17 +44,11 @@ final class ChatListViewModel {
|| $0.lastMessage.lowercased().contains(query)
}
}
return result
}
var pinnedDialogs: [Dialog] {
filteredDialogs.filter(\.isPinned)
}
var unpinnedDialogs: [Dialog] {
filteredDialogs.filter { !$0.isPinned }
}
var pinnedDialogs: [Dialog] { filteredDialogs.filter(\.isPinned) }
var unpinnedDialogs: [Dialog] { filteredDialogs.filter { !$0.isPinned } }
var totalUnreadCount: Int {
DialogRepository.shared.sortedDialogs
@@ -54,12 +58,6 @@ final class ChatListViewModel {
var hasUnread: Bool { totalUnreadCount > 0 }
/// True when searching and no local results shows server results section
var showServerResults: Bool {
let query = searchQuery.trimmingCharacters(in: .whitespaces)
return !query.isEmpty
}
// MARK: - Actions
func setSearchQuery(_ query: String) {
@@ -85,6 +83,30 @@ final class ChatListViewModel {
// MARK: - Server Search
private func setupSearchCallback() {
Self.logger.debug("Setting up search callback")
ProtocolManager.shared.onSearchResult = { [weak self] packet in
DispatchQueue.main.async { [weak self] in
guard let self else {
Self.logger.debug("Search callback: self is nil")
return
}
Self.logger.debug("📥 Search results received: \(packet.users.count) users")
self.serverSearchResults = packet.users
self.isServerSearching = false
Self.logger.debug("📥 isServerSearching=\(self.isServerSearching), count=\(self.serverSearchResults.count)")
for user in packet.users {
DialogRepository.shared.updateUserInfo(
publicKey: user.publicKey,
title: user.title,
username: user.username,
verified: user.verified
)
}
}
}
}
private func triggerServerSearch() {
searchTask?.cancel()
searchTask = nil
@@ -97,15 +119,11 @@ final class ChatListViewModel {
return
}
if trimmed == lastSearchedText {
return
}
if trimmed == lastSearchedText { return }
isServerSearching = true
searchTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(1))
guard let self, !Task.isCancelled else { return }
let currentQuery = self.searchQuery.trimmingCharacters(in: .whitespaces)
@@ -113,41 +131,65 @@ final class ChatListViewModel {
let connState = ProtocolManager.shared.connectionState
let hash = SessionManager.shared.privateKeyHash
print("[Search] connState=\(connState.rawValue), hasHash=\(hash != nil), query='\(currentQuery)'")
guard connState == .authenticated, let hash else {
print("[Search] NOT AUTHENTICATED - aborting")
self.isServerSearching = false
return
}
self.lastSearchedText = currentQuery
var packet = PacketSearch()
packet.privateKey = hash
packet.search = currentQuery
print("[Search] Sending PacketSearch for '\(currentQuery)'")
Self.logger.debug("📤 Sending search packet for '\(currentQuery)' with hash \(hash.prefix(10))...")
ProtocolManager.shared.sendPacket(packet)
}
}
private func setupSearchCallback() {
print("[Search] Setting up search callback")
ProtocolManager.shared.onSearchResult = { [weak self] packet in
print("[Search] CALLBACK: received \(packet.users.count) users")
Task { @MainActor [weak self] in
guard let self else { return }
self.serverSearchResults = packet.users
self.isServerSearching = false
// MARK: - Recent Searches
for user in packet.users {
DialogRepository.shared.updateUserInfo(
publicKey: user.publicKey,
title: user.title,
username: user.username
)
}
}
func addToRecent(_ user: SearchUser) {
let recent = RecentSearch(
publicKey: user.publicKey,
title: user.title,
username: user.username,
lastSeenText: user.online == 1 ? "online" : "last seen recently"
)
recentSearches.removeAll { $0.publicKey == user.publicKey }
recentSearches.insert(recent, at: 0)
if recentSearches.count > Self.maxRecent {
recentSearches = Array(recentSearches.prefix(Self.maxRecent))
}
saveRecentSearches()
}
func removeRecentSearch(publicKey: String) {
recentSearches.removeAll { $0.publicKey == publicKey }
saveRecentSearches()
}
func clearRecentSearches() {
recentSearches = []
saveRecentSearches()
}
private func loadRecentSearches() {
if let data = UserDefaults.standard.data(forKey: recentKey),
let list = try? JSONDecoder().decode([RecentSearch].self, from: data) {
recentSearches = list
return
}
let oldKey = "rosetta_recent_searches"
if let data = UserDefaults.standard.data(forKey: oldKey),
let list = try? JSONDecoder().decode([RecentSearch].self, from: data) {
recentSearches = list
saveRecentSearches()
UserDefaults.standard.removeObject(forKey: oldKey)
}
}
private func saveRecentSearches() {
guard let data = try? JSONEncoder().encode(recentSearches) else { return }
UserDefaults.standard.set(data, forKey: recentKey)
}
}

View File

@@ -0,0 +1,50 @@
import SwiftUI
// MARK: - ChatRowShimmerView
/// Placeholder shimmer row displayed during chat list loading.
struct ChatRowShimmerView: View {
@State private var phase: CGFloat = 0
@Environment(\.colorScheme) private var colorScheme
var body: some View {
HStack(spacing: 12) {
Circle()
.fill(shimmerGradient)
.frame(width: 62, height: 62)
VStack(alignment: .leading, spacing: 8) {
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(width: 140, height: 14)
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(width: 200, height: 12)
}
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.task {
withAnimation(.linear(duration: 1.4).repeatForever(autoreverses: false)) {
phase = 1
}
}
}
private var shimmerGradient: LinearGradient {
let baseOpacity = colorScheme == .dark ? 0.06 : 0.08
let peakOpacity = colorScheme == .dark ? 0.12 : 0.16
return LinearGradient(
colors: [
Color.gray.opacity(baseOpacity),
Color.gray.opacity(peakOpacity),
Color.gray.opacity(baseOpacity),
],
startPoint: UnitPoint(x: phase - 0.4, y: 0),
endPoint: UnitPoint(x: phase + 0.4, y: 0)
)
}
}

View File

@@ -2,11 +2,14 @@ import SwiftUI
// MARK: - ChatRowView
/// Chat row matching Figma spec:
/// Row: paddingLeft=10, paddingRight=16, height=78
/// Avatar: 62px + 10pt right padding
/// Title: SFPro-Medium 17pt, message: SFPro-Regular 15pt
/// Time: SFPro-Regular 14pt, subtitle color: #3C3C43/60%
/// Chat row matching Figma "Row - Chats" component spec:
/// Row: height 78, paddingLeft 10, paddingRight 16, vertical center
/// Avatar: 62px circle, 10pt trailing padding
/// Title: SF Pro Medium 17pt, tracking -0.43, primary color
/// Message: SF Pro Regular 15pt, tracking -0.23, secondary color
/// Time: SF Pro Regular 14pt, tracking -0.23, secondary color
/// Badges gap: 6pt verified 12px, muted 12px
/// Trailing: pt 8, pb 14 readStatus + time (gap 2), pin/count at bottom
struct ChatRowView: View {
let dialog: Dialog
@@ -38,21 +41,27 @@ private extension ChatRowView {
}
}
// MARK: - Content Section
// MARK: - Content Section (two-column: title+detail | trailing accessories)
private extension ChatRowView {
var contentSection: some View {
VStack(alignment: .leading, spacing: 0) {
Spacer(minLength: 0)
titleRow
Spacer().frame(height: 3)
subtitleRow
Spacer(minLength: 0)
HStack(alignment: .center, spacing: 6) {
// Left column: title + message
VStack(alignment: .leading, spacing: 2) {
titleRow
messageRow
}
.frame(maxWidth: .infinity, alignment: .leading)
.clipped()
// Right column: time + pin/badge
trailingColumn
}
.frame(height: 63)
}
}
// MARK: - Title Row (name + badges + delivery + time)
// MARK: - Title Row (name + badges)
private extension ChatRowView {
var titleRow: some View {
@@ -63,10 +72,11 @@ private extension ChatRowView {
.foregroundStyle(RosettaColors.Adaptive.text)
.lineLimit(1)
if dialog.isVerified {
Image(systemName: "checkmark.seal.fill")
.font(.system(size: 14))
.foregroundStyle(RosettaColors.figmaBlue)
if !dialog.isSavedMessages && dialog.effectiveVerified > 0 {
VerifiedBadge(
verified: dialog.effectiveVerified,
size: 12
)
}
if dialog.isMuted {
@@ -74,47 +84,19 @@ private extension ChatRowView {
.font(.system(size: 12))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
}
Spacer(minLength: 4)
if dialog.lastMessageFromMe && !dialog.isSavedMessages {
deliveryIcon
}
Text(formattedTime)
.font(.system(size: 14))
.foregroundStyle(
dialog.unreadCount > 0 && !dialog.isMuted
? RosettaColors.figmaBlue
: RosettaColors.Adaptive.textSecondary
)
}
}
}
// MARK: - Subtitle Row (message + pin + badge)
// MARK: - Message Row
private extension ChatRowView {
var subtitleRow: some View {
HStack(spacing: 4) {
Text(messageText)
.font(.system(size: 15))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.lineLimit(1)
Spacer(minLength: 4)
if dialog.isPinned && dialog.unreadCount == 0 {
Image(systemName: "pin.fill")
.font(.system(size: 13))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.rotationEffect(.degrees(45))
}
if dialog.unreadCount > 0 {
unreadBadge
}
}
var messageRow: some View {
Text(messageText)
.font(.system(size: 15))
.tracking(-0.23)
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.lineLimit(1)
}
var messageText: String {
@@ -123,6 +105,48 @@ private extension ChatRowView {
}
return dialog.lastMessage
}
}
// MARK: - Trailing Column (time + delivery on top, pin/badge on bottom)
private extension ChatRowView {
var trailingColumn: some View {
VStack(alignment: .trailing, spacing: 0) {
// Top: read status + time
HStack(spacing: 2) {
if dialog.lastMessageFromMe && !dialog.isSavedMessages {
deliveryIcon
}
Text(formattedTime)
.font(.system(size: 14))
.tracking(-0.23)
.foregroundStyle(
dialog.unreadCount > 0 && !dialog.isMuted
? RosettaColors.figmaBlue
: RosettaColors.Adaptive.textSecondary
)
}
.padding(.top, 2)
Spacer(minLength: 0)
// Bottom: pin or unread badge
HStack(spacing: 8) {
if dialog.isPinned && dialog.unreadCount == 0 {
Image(systemName: "pin.fill")
.font(.system(size: 15))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.rotationEffect(.degrees(45))
}
if dialog.unreadCount > 0 {
unreadBadge
}
}
.padding(.bottom, 2)
}
}
@ViewBuilder
var deliveryIcon: some View {
@@ -160,9 +184,11 @@ private extension ChatRowView {
return Text(text)
.font(.system(size: 15))
.tracking(-0.23)
.foregroundStyle(.white)
.padding(.horizontal, 4)
.frame(minWidth: 20, minHeight: 20)
.frame(maxWidth: 37)
.background {
Capsule()
.fill(isMuted ? Color(hex: 0x787880) : RosettaColors.figmaBlue)
@@ -208,7 +234,7 @@ private extension ChatRowView {
lastMessage: "Hey, how are you?",
lastMessageTimestamp: Int64(Date().timeIntervalSince1970 * 1000),
unreadCount: 3, isOnline: true, lastSeen: 0,
isVerified: true, iHaveSent: true,
verified: 1, iHaveSent: true,
isPinned: false, isMuted: false,
lastMessageFromMe: true, lastMessageDelivered: .read
)

View File

@@ -0,0 +1,86 @@
import SwiftUI
// MARK: - SearchSkeletonView
/// Telegram-style skeleton loading for search results.
/// Matches the Figma chat row layout: 62px avatar, two-line text, trailing time.
struct SearchSkeletonView: View {
@State private var phase: CGFloat = 0
var body: some View {
ScrollView {
VStack(spacing: 0) {
ForEach(0..<7, id: \.self) { index in
skeletonRow(index: index)
if index < 6 {
Divider()
.foregroundStyle(RosettaColors.Adaptive.divider)
.padding(.leading, 82)
}
}
}
}
.scrollDisabled(true)
.task {
withAnimation(.linear(duration: 1.5).repeatForever(autoreverses: false)) {
phase = 1
}
}
}
private func skeletonRow(index: Int) -> some View {
HStack(spacing: 0) {
// Avatar 62pt circle matching Figma
Circle()
.fill(shimmerGradient)
.frame(width: 62, height: 62)
.padding(.leading, 10)
.padding(.trailing, 10)
// Text block two lines matching Figma row heights
VStack(alignment: .leading, spacing: 8) {
// Title line name width varies per row
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(width: titleWidth(for: index), height: 16)
// Subtitle line message preview
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(width: subtitleWidth(for: index), height: 14)
}
Spacer()
// Trailing time placeholder
RoundedRectangle(cornerRadius: 3)
.fill(shimmerGradient)
.frame(width: 40, height: 12)
.padding(.trailing, 16)
}
.frame(height: 78)
}
// Vary widths to look natural (not uniform blocks)
private func titleWidth(for index: Int) -> CGFloat {
let widths: [CGFloat] = [130, 100, 160, 90, 140, 110, 150]
return widths[index % widths.count]
}
private func subtitleWidth(for index: Int) -> CGFloat {
let widths: [CGFloat] = [200, 170, 220, 150, 190, 180, 210]
return widths[index % widths.count]
}
private var shimmerGradient: LinearGradient {
LinearGradient(
colors: [
Color.gray.opacity(0.08),
Color.gray.opacity(0.15),
Color.gray.opacity(0.08),
],
startPoint: UnitPoint(x: phase - 0.4, y: 0),
endPoint: UnitPoint(x: phase + 0.4, y: 0)
)
}
}