Исправление бесконечного рендер-цикла SearchView и поиск по публичному ключу

- SearchViewModel: заменён @Observable на ObservableObject + @Published
  (устранён infinite body loop SearchView → 99% CPU фриз после логина)
- SearchView: @State → @StateObject, RecentSection: @ObservedObject
- Добавлен клиентский поиск по публичному ключу (сервер ищет только по нику)
- ChatDetailView: убран @State на DialogRepository singleton
- ChatListView: замена closure на @Binding, убран DispatchQueue.main.async
- MainTabView: убран пустой onChange, замена closure на @Binding
- SettingsViewModel: конвертирован в ObservableObject
- Добавлены debug-принты для отладки рендер-циклов

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-08 05:14:54 +05:00
parent 6bef51e235
commit e26d94b268
9 changed files with 442 additions and 309 deletions

View File

@@ -6,10 +6,10 @@ struct ChatDetailView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@ObservedObject private var messageRepository = MessageRepository.shared @ObservedObject private var messageRepository = MessageRepository.shared
@State private var dialogRepository = DialogRepository.shared
@State private var messageText = "" @State private var messageText = ""
@State private var sendError: String? @State private var sendError: String?
@State private var isViewActive = false
@FocusState private var isInputFocused: Bool @FocusState private var isInputFocused: Bool
private var currentPublicKey: String { private var currentPublicKey: String {
@@ -17,7 +17,7 @@ struct ChatDetailView: View {
} }
private var dialog: Dialog? { private var dialog: Dialog? {
dialogRepository.dialogs[route.publicKey] DialogRepository.shared.dialogs[route.publicKey]
} }
private var messages: [ChatMessage] { private var messages: [ChatMessage] {
@@ -106,18 +106,21 @@ struct ChatDetailView: View {
.toolbar { chatDetailToolbar } // твой header тут .toolbar { chatDetailToolbar } // твой header тут
.toolbar(.hidden, for: .tabBar) .toolbar(.hidden, for: .tabBar)
.task { .task {
isViewActive = true
// Request user info (non-mutating, won't trigger list rebuild) // Request user info (non-mutating, won't trigger list rebuild)
requestUserInfoIfNeeded() requestUserInfoIfNeeded()
// Delay ALL dialog mutations to let navigation transition complete. // Delay ALL dialog mutations to let navigation transition complete.
// Without this, DialogRepository update rebuilds ChatListView's ForEach // Without this, DialogRepository update rebuilds ChatListView's ForEach
// mid-navigation, recreating the NavigationLink and canceling the push. // mid-navigation, recreating the NavigationLink and canceling the push.
try? await Task.sleep(for: .milliseconds(600)) try? await Task.sleep(for: .milliseconds(600))
guard isViewActive else { return }
activateDialog() activateDialog()
markDialogAsRead() markDialogAsRead()
// Subscribe to opponent's online status (Android parity) only after settled // Subscribe to opponent's online status (Android parity) only after settled
SessionManager.shared.subscribeToOnlineStatus(publicKey: route.publicKey) SessionManager.shared.subscribeToOnlineStatus(publicKey: route.publicKey)
} }
.onDisappear { .onDisappear {
isViewActive = false
messageRepository.setDialogActive(route.publicKey, isActive: false) messageRepository.setDialogActive(route.publicKey, isActive: false)
} }
} }
@@ -306,16 +309,20 @@ private extension ChatDetailView {
try? await Task.sleep(for: .milliseconds(120)) try? await Task.sleep(for: .milliseconds(120))
scrollToBottom(proxy: proxy, animated: false) scrollToBottom(proxy: proxy, animated: false)
} }
markDialogAsRead() // markDialogAsRead() removed already handled in .task with 600ms delay.
// Calling it here immediately mutates DialogRepository, triggering
// ChatListView ForEach rebuild mid-navigation and cancelling the push.
} }
.onChange(of: messages.count) { _, _ in .onChange(of: messages.count) { _, _ in
scrollToBottom(proxy: proxy, animated: true) scrollToBottom(proxy: proxy, animated: true)
markDialogAsRead() if isViewActive {
markDialogAsRead()
}
} }
.onChange(of: isInputFocused) { _, focused in .onChange(of: isInputFocused) { _, focused in
guard focused else { return } guard focused else { return }
Task { @MainActor in Task { @MainActor in
try? await Task.sleep(for: .milliseconds(80)) try? await Task.sleep(nanoseconds: 80_000_000)
scrollToBottom(proxy: proxy, animated: true) scrollToBottom(proxy: proxy, animated: true)
} }
} }
@@ -331,28 +338,33 @@ private extension ChatDetailView {
let outgoing = message.isFromMe(myPublicKey: currentPublicKey) let outgoing = message.isFromMe(myPublicKey: currentPublicKey)
let messageText = message.text.isEmpty ? " " : message.text let messageText = message.text.isEmpty ? " " : message.text
// Text determines bubble width; timestamp overlays at bottom-trailing. // Telegram-style compact bubble: inline time+status at bottom-trailing.
// minWidth ensures the bubble is wide enough for the timestamp row. // Right padding reserves space for the timestamp overlay (64pt outgoing, 48pt incoming).
Text(messageText) Text(messageText)
.font(.system(size: 16, weight: .regular)) .font(.system(size: 17, weight: .regular))
.tracking(-0.43)
.foregroundStyle(outgoing ? Color.white : RosettaColors.Adaptive.text) .foregroundStyle(outgoing ? Color.white : RosettaColors.Adaptive.text)
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
.lineSpacing(0) .lineSpacing(0)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
.padding(.horizontal, 14) .padding(.leading, 11)
.padding(.top, 8) .padding(.trailing, outgoing ? 64 : 48)
.padding(.bottom, 22) .padding(.vertical, 5)
.frame(minWidth: outgoing ? 90 : 70, alignment: .leading) .frame(minWidth: outgoing ? 86 : 66, alignment: .leading)
.overlay(alignment: .bottomTrailing) { .overlay(alignment: .bottomTrailing) {
HStack(spacing: 4) { HStack(spacing: 3) {
Text(messageTime(message.timestamp)) Text(messageTime(message.timestamp))
.font(.system(size: 12, weight: .regular)) .font(.system(size: 11, weight: .regular))
.foregroundStyle(outgoing ? Color.white.opacity(0.72) : RosettaColors.Adaptive.textSecondary) .foregroundStyle(
outgoing
? Color.white.opacity(0.55)
: RosettaColors.Adaptive.textSecondary.opacity(0.6)
)
if outgoing { deliveryIndicator(message.deliveryStatus) } if outgoing { deliveryIndicator(message.deliveryStatus) }
} }
.padding(.trailing, 14) .padding(.trailing, 11)
.padding(.bottom, 6) .padding(.bottom, 5)
} }
.background { bubbleBackground(outgoing: outgoing, isTailVisible: isTailVisible) } .background { bubbleBackground(outgoing: outgoing, isTailVisible: isTailVisible) }
.frame(maxWidth: maxBubbleWidth, alignment: outgoing ? .trailing : .leading) .frame(maxWidth: maxBubbleWidth, alignment: outgoing ? .trailing : .leading)
@@ -529,8 +541,8 @@ private extension ChatDetailView {
@ViewBuilder @ViewBuilder
func bubbleBackground(outgoing: Bool, isTailVisible: Bool) -> some View { func bubbleBackground(outgoing: Bool, isTailVisible: Bool) -> some View {
let nearRadius: CGFloat = isTailVisible ? 6 : 17 let nearRadius: CGFloat = isTailVisible ? 8 : 18
let bubbleRadius: CGFloat = 17 let bubbleRadius: CGFloat = 18
let fill = outgoing ? RosettaColors.figmaBlue : incomingBubbleFill let fill = outgoing ? RosettaColors.figmaBlue : incomingBubbleFill
if #available(iOS 17.0, *) { if #available(iOS 17.0, *) {
UnevenRoundedRectangle( UnevenRoundedRectangle(
@@ -561,34 +573,21 @@ private extension ChatDetailView {
strokeOpacity: Double = 0.18, strokeOpacity: Double = 0.18,
strokeColor: Color = RosettaColors.Adaptive.border strokeColor: Color = RosettaColors.Adaptive.border
) -> some View { ) -> some View {
if #available(iOS 26.0, *) { let border = strokeColor.opacity(max(0.28, strokeOpacity))
switch shape { switch shape {
case .capsule: case .capsule:
Capsule().fill(.clear).glassEffect(.regular, in: .capsule) Capsule()
case .circle: .fill(RosettaColors.adaptive(light: Color(hex: 0xF2F2F7), dark: Color(hex: 0x1C1C1E)))
Circle().fill(.clear).glassEffect(.regular, in: .circle) .overlay(Capsule().stroke(border, lineWidth: 0.8))
case let .rounded(radius): case .circle:
RoundedRectangle(cornerRadius: radius, style: .continuous) Circle()
.fill(.clear) .fill(RosettaColors.adaptive(light: Color(hex: 0xF2F2F7), dark: Color(hex: 0x1C1C1E)))
.glassEffect(.regular, in: RoundedRectangle(cornerRadius: radius, style: .continuous)) .overlay(Circle().stroke(border, lineWidth: 0.8))
} case let .rounded(radius):
} else { let rounded = RoundedRectangle(cornerRadius: radius, style: .continuous)
let border = strokeColor.opacity(max(0.28, strokeOpacity)) rounded
switch shape { .fill(RosettaColors.adaptive(light: Color(hex: 0xF2F2F7), dark: Color(hex: 0x1C1C1E)))
case .capsule: .overlay(rounded.stroke(border, lineWidth: 0.8))
Capsule()
.fill(.ultraThinMaterial)
.overlay(Capsule().stroke(border, lineWidth: 0.8))
case .circle:
Circle()
.fill(.ultraThinMaterial)
.overlay(Circle().stroke(border, lineWidth: 0.8))
case let .rounded(radius):
let rounded = RoundedRectangle(cornerRadius: radius, style: .continuous)
rounded
.fill(.ultraThinMaterial)
.overlay(rounded.stroke(border, lineWidth: 0.8))
}
} }
} }
@@ -625,12 +624,12 @@ private extension ChatDetailView {
Image(systemName: "checkmark").offset(x: 3) Image(systemName: "checkmark").offset(x: 3)
Image(systemName: "checkmark") Image(systemName: "checkmark")
} }
.font(.system(size: 10.5, weight: .semibold)) .font(.system(size: 9.5, weight: .semibold))
.foregroundStyle(deliveryTint(status)) .foregroundStyle(deliveryTint(status))
.frame(width: 13, alignment: .trailing) .frame(width: 12, alignment: .trailing)
default: default:
Image(systemName: deliveryIcon(status)) Image(systemName: deliveryIcon(status))
.font(.system(size: 11, weight: .semibold)) .font(.system(size: 10, weight: .semibold))
.foregroundStyle(deliveryTint(status)) .foregroundStyle(deliveryTint(status))
} }
} }
@@ -670,7 +669,7 @@ private extension ChatDetailView {
func activateDialog() { func activateDialog() {
// Only update existing dialogs; don't create ghost entries from search. // Only update existing dialogs; don't create ghost entries from search.
// New dialogs are created when messages are sent/received (SessionManager). // New dialogs are created when messages are sent/received (SessionManager).
if dialogRepository.dialogs[route.publicKey] != nil { if DialogRepository.shared.dialogs[route.publicKey] != nil {
DialogRepository.shared.ensureDialog( DialogRepository.shared.ensureDialog(
opponentKey: route.publicKey, opponentKey: route.publicKey,
title: route.title, title: route.title,

View File

@@ -1,16 +1,40 @@
import Combine
import SwiftUI import SwiftUI
// MARK: - Navigation State (survives parent re-renders)
@MainActor
final class ChatListNavigationState: ObservableObject {
@Published var path: [ChatRoute] = []
}
// MARK: - ChatListView // MARK: - ChatListView
/// The root chat list screen.
///
/// **IMPORTANT:** This view's `body` must NOT read any `@Observable` singleton
/// (`ProtocolManager`, `DialogRepository`, `AccountManager`, `SessionManager`)
/// directly. Such reads create implicit Observation tracking, causing the
/// NavigationStack to rebuild on every property change (e.g. during handshake)
/// and triggering "Update NavigationRequestObserver tried to update multiple
/// times per frame" app freeze.
///
/// All `@Observable` access is isolated in dedicated child views:
/// - `DeviceVerificationBannersContainer` `ProtocolManager`
/// - `ToolbarStoriesAvatar` `AccountManager` / `SessionManager`
/// - `ChatListDialogContent` `DialogRepository` (via ViewModel)
struct ChatListView: View { struct ChatListView: View {
@Binding var isSearchActive: Bool @Binding var isSearchActive: Bool
var onChatDetailVisibilityChange: ((Bool) -> Void)? = nil @Binding var isDetailPresented: Bool
@StateObject private var viewModel = ChatListViewModel() @StateObject private var viewModel = ChatListViewModel()
@StateObject private var navigationState = ChatListNavigationState()
@State private var searchText = "" @State private var searchText = ""
@State private var navigationPath: [ChatRoute] = []
@MainActor static var _bodyCount = 0
var body: some View { var body: some View {
NavigationStack(path: $navigationPath) { let _ = Self._bodyCount += 1
let _ = print("🟡 ChatListView.body #\(Self._bodyCount)")
NavigationStack(path: $navigationState.path) {
ZStack { ZStack {
RosettaColors.Adaptive.background RosettaColors.Adaptive.background
.ignoresSafeArea() .ignoresSafeArea()
@@ -23,7 +47,7 @@ struct ChatListView: View {
onOpenDialog: { route in onOpenDialog: { route in
isSearchActive = false isSearchActive = false
searchText = "" searchText = ""
navigationPath.append(route) navigationState.path.append(route)
} }
) )
} else { } else {
@@ -46,16 +70,16 @@ struct ChatListView: View {
.navigationDestination(for: ChatRoute.self) { route in .navigationDestination(for: ChatRoute.self) { route in
ChatDetailView( ChatDetailView(
route: route, route: route,
onPresentedChange: { isPresented in onPresentedChange: { presented in
onChatDetailVisibilityChange?(isPresented) isDetailPresented = presented
} }
) )
} }
.onAppear { .onAppear {
onChatDetailVisibilityChange?(!navigationPath.isEmpty) isDetailPresented = !navigationState.path.isEmpty
} }
.onChange(of: navigationPath) { _, newPath in .onChange(of: navigationState.path) { _, newPath in
onChatDetailVisibilityChange?(!newPath.isEmpty) isDetailPresented = !newPath.isEmpty
} }
} }
.tint(RosettaColors.figmaBlue) .tint(RosettaColors.figmaBlue)
@@ -68,36 +92,129 @@ private extension ChatListView {
@ViewBuilder @ViewBuilder
var normalContent: some View { var normalContent: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
deviceVerificationBanners // Isolated view reads ProtocolManager (@Observable) without
// polluting ChatListView's observation scope.
DeviceVerificationBannersContainer()
if viewModel.filteredDialogs.isEmpty && !viewModel.isLoading { // Isolated view reads DialogRepository (@Observable) via viewModel
ChatEmptyStateView(searchText: "") // without polluting ChatListView's observation scope.
} else { ChatListDialogContent(
dialogList viewModel: viewModel,
} navigationState: navigationState
}
}
@ViewBuilder
var deviceVerificationBanners: some View {
let protocol_ = ProtocolManager.shared
// Banner 1: THIS device needs approval from another device
if protocol_.connectionState == .deviceVerificationRequired {
DeviceWaitingApprovalBanner()
}
// Banner 2: ANOTHER device needs approval from THIS device
if let pendingDevice = protocol_.pendingDeviceVerification {
DeviceApprovalBanner(
device: pendingDevice,
onAccept: { protocol_.acceptDevice(pendingDevice.deviceId) },
onDecline: { protocol_.declineDevice(pendingDevice.deviceId) }
) )
} }
} }
}
var dialogList: some View { // MARK: - Toolbar
private extension ChatListView {
@ToolbarContentBuilder
var toolbarContent: some ToolbarContent {
ToolbarItem(placement: .navigationBarLeading) {
Button { } label: {
Text("Edit")
.font(.system(size: 17, weight: .medium))
.foregroundStyle(RosettaColors.Adaptive.text)
}
}
ToolbarItem(placement: .principal) {
HStack(spacing: 4) {
// Isolated view reads AccountManager & SessionManager (@Observable)
// without polluting ChatListView's observation scope.
ToolbarStoriesAvatar()
Text("Chats")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.text)
}
}
ToolbarItemGroup(placement: .navigationBarTrailing) {
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")
}
}
}
}
// MARK: - Toolbar Stories Avatar (observation-isolated)
/// Reads `AccountManager` and `SessionManager` in its own observation scope.
/// Changes to these `@Observable` singletons only re-render this small view,
/// not the parent ChatListView / NavigationStack.
private struct ToolbarStoriesAvatar: View {
@MainActor static var _bodyCount = 0
var body: some View {
let _ = Self._bodyCount += 1
let _ = print("🟣 ToolbarStoriesAvatar.body #\(Self._bodyCount)")
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: - Device Verification Banners (observation-isolated)
/// Reads `ProtocolManager` in its own observation scope.
/// During handshake, `connectionState` changes 4+ times rapidly this view
/// absorbs those re-renders instead of cascading them to the NavigationStack.
private struct DeviceVerificationBannersContainer: View {
@MainActor static var _bodyCount = 0
var body: some View {
let _ = Self._bodyCount += 1
let _ = print("⚪ DeviceVerificationBanners.body #\(Self._bodyCount)")
let proto = ProtocolManager.shared
if proto.connectionState == .deviceVerificationRequired {
DeviceWaitingApprovalBanner()
}
if let pendingDevice = proto.pendingDeviceVerification {
DeviceApprovalBanner(
device: pendingDevice,
onAccept: { proto.acceptDevice(pendingDevice.deviceId) },
onDecline: { proto.declineDevice(pendingDevice.deviceId) }
)
}
}
}
// MARK: - Dialog Content (observation-isolated)
/// Reads `DialogRepository` (via ViewModel) in its own observation scope.
/// Changes to dialogs only re-render this list, not the NavigationStack.
private struct ChatListDialogContent: View {
@ObservedObject var viewModel: ChatListViewModel
@ObservedObject var navigationState: ChatListNavigationState
@MainActor static var _bodyCount = 0
var body: some View {
let _ = Self._bodyCount += 1
let _ = print("🔶 ChatListDialogContent.body #\(Self._bodyCount)")
if viewModel.filteredDialogs.isEmpty && !viewModel.isLoading {
ChatEmptyStateView(searchText: "")
} else {
dialogList
}
}
private var dialogList: some View {
List { List {
if viewModel.isLoading { if viewModel.isLoading {
ForEach(0..<8, id: \.self) { _ in ForEach(0..<8, id: \.self) { _ in
@@ -128,9 +245,9 @@ private extension ChatListView {
.scrollDismissesKeyboard(.immediately) .scrollDismissesKeyboard(.immediately)
} }
func chatRow(_ dialog: Dialog) -> some View { private func chatRow(_ dialog: Dialog) -> some View {
Button { Button {
navigationPath.append(ChatRoute(dialog: dialog)) navigationState.path.append(ChatRoute(dialog: dialog))
} label: { } label: {
ChatRowView(dialog: dialog) ChatRowView(dialog: dialog)
} }
@@ -172,58 +289,6 @@ private extension ChatListView {
} }
} }
// MARK: - Toolbar
private extension ChatListView {
@ToolbarContentBuilder
var toolbarContent: some ToolbarContent {
ToolbarItem(placement: .navigationBarLeading) {
Button { } label: {
Text("Edit")
.font(.system(size: 17, weight: .medium))
.foregroundStyle(RosettaColors.Adaptive.text)
}
}
ToolbarItem(placement: .principal) {
HStack(spacing: 4) {
storiesAvatars
Text("Chats")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.text)
}
}
ToolbarItemGroup(placement: .navigationBarTrailing) {
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")
}
}
}
@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: - Device Waiting Approval Banner // MARK: - Device Waiting Approval Banner
/// Shown when THIS device needs approval from another Rosetta device. /// Shown when THIS device needs approval from another Rosetta device.
@@ -303,4 +368,4 @@ private struct DeviceApprovalBanner: View {
} }
} }
#Preview { ChatListView(isSearchActive: .constant(false), onChatDetailVisibilityChange: nil) } #Preview { ChatListView(isSearchActive: .constant(false), isDetailPresented: .constant(false)) }

View File

@@ -5,6 +5,7 @@ import SwiftUI
struct SearchResultsSection: View { struct SearchResultsSection: View {
let isSearching: Bool let isSearching: Bool
let searchResults: [SearchUser] let searchResults: [SearchUser]
let currentPublicKey: String
var onSelectUser: (SearchUser) -> Void var onSelectUser: (SearchUser) -> Void
var body: some View { var body: some View {
@@ -58,7 +59,7 @@ private extension SearchResultsSection {
private extension SearchResultsSection { private extension SearchResultsSection {
func searchResultRow(_ user: SearchUser) -> some View { func searchResultRow(_ user: SearchUser) -> some View {
let isSelf = user.publicKey == SessionManager.shared.currentPublicKey let isSelf = user.publicKey == currentPublicKey
let initials = isSelf ? "S" : RosettaColors.initials(name: user.title, publicKey: user.publicKey) let initials = isSelf ? "S" : RosettaColors.initials(name: user.title, publicKey: user.publicKey)
let colorIdx = RosettaColors.avatarColorIndex(for: user.publicKey) let colorIdx = RosettaColors.avatarColorIndex(for: user.publicKey)
@@ -118,6 +119,7 @@ private extension SearchResultsSection {
SearchResultsSection( SearchResultsSection(
isSearching: false, isSearching: false,
searchResults: [], searchResults: [],
currentPublicKey: "",
onSelectUser: { _ in } onSelectUser: { _ in }
) )
} }

View File

@@ -3,12 +3,15 @@ import SwiftUI
// MARK: - SearchView // MARK: - SearchView
struct SearchView: View { struct SearchView: View {
var onChatDetailVisibilityChange: ((Bool) -> Void)? = nil @Binding var isDetailPresented: Bool
@State private var viewModel = SearchViewModel() @StateObject private var viewModel = SearchViewModel()
@State private var searchText = "" @State private var searchText = ""
@State private var navigationPath: [ChatRoute] = [] @State private var navigationPath: [ChatRoute] = []
@MainActor static var _bodyCount = 0
var body: some View { var body: some View {
let _ = Self._bodyCount += 1
let _ = print("🔵 SearchView.body #\(Self._bodyCount)")
NavigationStack(path: $navigationPath) { NavigationStack(path: $navigationPath) {
ZStack(alignment: .bottom) { ZStack(alignment: .bottom) {
RosettaColors.Adaptive.background RosettaColors.Adaptive.background
@@ -17,8 +20,11 @@ struct SearchView: View {
ScrollView { ScrollView {
VStack(spacing: 0) { VStack(spacing: 0) {
if searchText.isEmpty { if searchText.isEmpty {
favoriteContactsRow FavoriteContactsRow(navigationPath: $navigationPath)
recentSection RecentSection(
viewModel: viewModel,
navigationPath: $navigationPath
)
} else { } else {
searchResultsContent searchResultsContent
} }
@@ -37,15 +43,15 @@ struct SearchView: View {
ChatDetailView( ChatDetailView(
route: route, route: route,
onPresentedChange: { isPresented in onPresentedChange: { isPresented in
onChatDetailVisibilityChange?(isPresented) isDetailPresented = isPresented
} }
) )
} }
.onAppear { .onAppear {
onChatDetailVisibilityChange?(!navigationPath.isEmpty) isDetailPresented = !navigationPath.isEmpty
} }
.onChange(of: navigationPath) { _, newPath in .onChange(of: navigationPath) { _, newPath in
onChatDetailVisibilityChange?(!newPath.isEmpty) isDetailPresented = !newPath.isEmpty
} }
} }
} }
@@ -128,11 +134,17 @@ private extension SearchView {
} }
// MARK: - Favorite Contacts (Figma: horizontal scroll at top) // MARK: - Favorite Contacts (isolated reads DialogRepository in own scope)
private extension SearchView { /// Isolated child view so that `DialogRepository.shared.sortedDialogs` observation
@ViewBuilder /// does NOT propagate to `SearchView`'s NavigationStack.
var favoriteContactsRow: some View { private struct FavoriteContactsRow: View {
@Binding var navigationPath: [ChatRoute]
@MainActor static var _bodyCount = 0
var body: some View {
let _ = Self._bodyCount += 1
let _ = print("🟠 FavoriteContactsRow.body #\(Self._bodyCount)")
let dialogs = DialogRepository.shared.sortedDialogs.prefix(10) let dialogs = DialogRepository.shared.sortedDialogs.prefix(10)
if !dialogs.isEmpty { if !dialogs.isEmpty {
ScrollView(.horizontal, showsIndicators: false) { ScrollView(.horizontal, showsIndicators: false) {
@@ -169,16 +181,22 @@ private extension SearchView {
} }
} }
// MARK: - Recent Section // MARK: - Recent Section (isolated reads SessionManager in own scope)
private extension SearchView { /// Isolated child view so that `SessionManager.shared.currentPublicKey` observation
@ViewBuilder /// does NOT propagate to `SearchView`'s NavigationStack.
var recentSection: some View { private struct RecentSection: View {
@ObservedObject var viewModel: SearchViewModel
@Binding var navigationPath: [ChatRoute]
@MainActor static var _bodyCount = 0
var body: some View {
let _ = Self._bodyCount += 1
let _ = print("🟤 RecentSection.body #\(Self._bodyCount)")
if viewModel.recentSearches.isEmpty { if viewModel.recentSearches.isEmpty {
emptyState emptyState
} else { } else {
VStack(spacing: 0) { VStack(spacing: 0) {
// Section header
HStack { HStack {
Text("RECENT") Text("RECENT")
.font(.system(size: 13)) .font(.system(size: 13))
@@ -198,7 +216,6 @@ private extension SearchView {
.padding(.top, 8) .padding(.top, 8)
.padding(.bottom, 6) .padding(.bottom, 6)
// Recent items
ForEach(viewModel.recentSearches, id: \.publicKey) { user in ForEach(viewModel.recentSearches, id: \.publicKey) { user in
recentRow(user) recentRow(user)
} }
@@ -206,7 +223,7 @@ private extension SearchView {
} }
} }
var emptyState: some View { private var emptyState: some View {
VStack(spacing: 16) { VStack(spacing: 16) {
LottieView( LottieView(
animationName: "search", animationName: "search",
@@ -228,8 +245,9 @@ private extension SearchView {
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} }
func recentRow(_ user: RecentSearch) -> some View { private func recentRow(_ user: RecentSearch) -> some View {
let isSelf = user.publicKey == SessionManager.shared.currentPublicKey let currentPK = SessionManager.shared.currentPublicKey
let isSelf = user.publicKey == currentPK
let initials = isSelf ? "S" : RosettaColors.initials(name: user.title, publicKey: user.publicKey) let initials = isSelf ? "S" : RosettaColors.initials(name: user.title, publicKey: user.publicKey)
let colorIdx = RosettaColors.avatarColorIndex(for: user.publicKey) let colorIdx = RosettaColors.avatarColorIndex(for: user.publicKey)
@@ -245,7 +263,6 @@ private extension SearchView {
isSavedMessages: isSelf isSavedMessages: isSelf
) )
// Close button to remove from recent
Button { Button {
viewModel.removeRecentSearch(publicKey: user.publicKey) viewModel.removeRecentSearch(publicKey: user.publicKey)
} label: { } label: {
@@ -288,6 +305,7 @@ private extension SearchView {
SearchResultsSection( SearchResultsSection(
isSearching: viewModel.isSearching, isSearching: viewModel.isSearching,
searchResults: viewModel.searchResults, searchResults: viewModel.searchResults,
currentPublicKey: SessionManager.shared.currentPublicKey,
onSelectUser: { user in onSelectUser: { user in
viewModel.addToRecent(user) viewModel.addToRecent(user)
navigationPath.append(ChatRoute(user: user)) navigationPath.append(ChatRoute(user: user))
@@ -299,5 +317,5 @@ private extension SearchView {
// MARK: - Preview // MARK: - Preview
#Preview { #Preview {
SearchView(onChatDetailVisibilityChange: nil) SearchView(isDetailPresented: .constant(false))
} }

View File

@@ -4,19 +4,26 @@ import os
// MARK: - SearchViewModel // MARK: - SearchViewModel
@Observable /// Search view model with **cached** state.
///
/// Uses `ObservableObject` + `@Published` (NOT `@Observable`) to avoid
/// SwiftUI observation feedback loops when embedded inside a NavigationStack
/// within the tab pager. `@Observable` + `@State` caused infinite body
/// re-evaluations of SearchView (hundreds per second 99 % CPU freeze).
/// `ObservableObject` + `@StateObject` matches ChatListViewModel and
/// SettingsViewModel which are both stable.
@MainActor @MainActor
final class SearchViewModel { final class SearchViewModel: ObservableObject {
private static let logger = Logger(subsystem: "com.rosetta.messenger", category: "Search") private static let logger = Logger(subsystem: "com.rosetta.messenger", category: "Search")
// MARK: - State // MARK: - State
var searchQuery = "" @Published var searchQuery = ""
private(set) var searchResults: [SearchUser] = [] @Published private(set) var searchResults: [SearchUser] = []
private(set) var isSearching = false @Published private(set) var isSearching = false
private(set) var recentSearches: [RecentSearch] = [] @Published private(set) var recentSearches: [RecentSearch] = []
private var searchTask: Task<Void, Never>? private var searchTask: Task<Void, Never>?
private var lastSearchedText = "" private var lastSearchedText = ""
@@ -51,34 +58,28 @@ final class SearchViewModel {
} }
if trimmed == lastSearchedText { if trimmed == lastSearchedText {
return return
} }
isSearching = true isSearching = true
// Debounce 1 second (like Android) // Debounce 1 second (like Android)
searchTask = Task { [weak self] in searchTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(1)) try? await Task.sleep(for: .seconds(1))
guard let self, !Task.isCancelled else { guard let self, !Task.isCancelled else {
return return
} }
let currentQuery = self.searchQuery.trimmingCharacters(in: .whitespaces) let currentQuery = self.searchQuery.trimmingCharacters(in: .whitespaces)
guard !currentQuery.isEmpty, currentQuery == trimmed else { guard !currentQuery.isEmpty, currentQuery == trimmed else {
return return
} }
let connState = ProtocolManager.shared.connectionState let connState = ProtocolManager.shared.connectionState
let hash = SessionManager.shared.privateKeyHash ?? ProtocolManager.shared.privateHash let hash = SessionManager.shared.privateKeyHash ?? ProtocolManager.shared.privateHash
guard connState == .authenticated, let hash else { guard connState == .authenticated, let hash else {
self.isSearching = false self.isSearching = false
return return
} }
@@ -112,15 +113,27 @@ final class SearchViewModel {
searchHandlerToken = ProtocolManager.shared.addSearchResultHandler { [weak self] packet in searchHandlerToken = ProtocolManager.shared.addSearchResultHandler { [weak self] packet in
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
guard let self else { return } guard let self else { return }
guard !self.searchQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { let query = self.searchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
guard !query.isEmpty else {
self.isSearching = false self.isSearching = false
return return
} }
self.searchResults = packet.users // Merge server results with client-side public key matches.
// Server only matches by username; public key matching is local
// (same approach as Android).
var merged = packet.users
let serverKeys = Set(merged.map(\.publicKey))
let localMatches = self.findLocalPublicKeyMatches(query: query)
for match in localMatches where !serverKeys.contains(match.publicKey) {
merged.append(match)
}
self.searchResults = merged
self.isSearching = false self.isSearching = false
// Update dialog info from results // Update dialog info from server results
for user in packet.users { for user in packet.users {
DialogRepository.shared.updateUserInfo( DialogRepository.shared.updateUserInfo(
publicKey: user.publicKey, publicKey: user.publicKey,
@@ -134,6 +147,52 @@ final class SearchViewModel {
} }
} }
// MARK: - Client-Side Public Key Matching
/// Matches the query against local dialogs' public keys and the user's own
/// key (Saved Messages). The server only searches by username, so public
/// key look-ups must happen on the client (matches Android behaviour).
private func findLocalPublicKeyMatches(query: String) -> [SearchUser] {
let normalized = query.lowercased().replacingOccurrences(of: "0x", with: "")
// Only treat as a public key search when every character is hex
guard !normalized.isEmpty, normalized.allSatisfy(\.isHexDigit) else {
return []
}
var results: [SearchUser] = []
// Check own public key Saved Messages
let ownKey = SessionManager.shared.currentPublicKey.lowercased().replacingOccurrences(of: "0x", with: "")
if ownKey.hasPrefix(normalized) || ownKey == normalized {
results.append(SearchUser(
username: "",
title: "Saved Messages",
publicKey: SessionManager.shared.currentPublicKey,
verified: 0,
online: 1
))
}
// Check local dialogs
for dialog in DialogRepository.shared.dialogs.values {
let dialogKey = dialog.opponentKey.lowercased().replacingOccurrences(of: "0x", with: "")
guard dialogKey.hasPrefix(normalized) || dialogKey == normalized else { continue }
// Skip if it's our own key (already handled as Saved Messages)
guard dialog.opponentKey != SessionManager.shared.currentPublicKey else { continue }
results.append(SearchUser(
username: dialog.opponentUsername,
title: dialog.opponentTitle,
publicKey: dialog.opponentKey,
verified: dialog.verified,
online: dialog.isOnline ? 1 : 0
))
}
return results
}
private func normalizeSearchInput(_ input: String) -> String { private func normalizeSearchInput(_ input: String) -> String {
input.replacingOccurrences(of: "@", with: "") input.replacingOccurrences(of: "@", with: "")
.trimmingCharacters(in: .whitespacesAndNewlines) .trimmingCharacters(in: .whitespacesAndNewlines)

View File

@@ -5,53 +5,23 @@ struct MainTabView: View {
var onLogout: (() -> Void)? var onLogout: (() -> Void)?
@State private var selectedTab: RosettaTab = .chats @State private var selectedTab: RosettaTab = .chats
@State private var isChatSearchActive = false @State private var isChatSearchActive = false
@State private var tabSwipeState: TabBarSwipeState?
@State private var isChatListDetailPresented = false @State private var isChatListDetailPresented = false
@State private var isSearchDetailPresented = false @State private var isSearchDetailPresented = false
/// All tabs are pre-activated so that switching only changes the offset,
/// not the view structure. Creating a NavigationStack mid-animation causes
/// "Update NavigationRequestObserver tried to update multiple times per frame" freeze.
@State private var activatedTabs: Set<RosettaTab> = Set(RosettaTab.interactionOrder)
/// When non-nil, the tab bar is being dragged and the pager follows interactively.
@State private var dragFractionalIndex: CGFloat?
var body: some View { var body: some View {
Group { let _ = Self._bodyCount += 1
if #available(iOS 26.0, *) { let _ = print("🔴 MainTabView.body #\(Self._bodyCount) search=\(isChatSearchActive) chatDetail=\(isChatListDetailPresented) searchDetail=\(isSearchDetailPresented)")
systemTabView mainTabView
} else {
legacyTabView
}
}
} }
@MainActor static var _bodyCount = 0
@available(iOS 26.0, *) private var mainTabView: some View {
private var systemTabView: some View {
TabView(selection: $selectedTab) {
ChatListView(
isSearchActive: $isChatSearchActive,
onChatDetailVisibilityChange: { isPresented in
isChatListDetailPresented = isPresented
}
)
.tabItem {
Label(RosettaTab.chats.label, systemImage: RosettaTab.chats.icon)
}
.tag(RosettaTab.chats)
.badgeIfNeeded(chatUnreadBadge)
SettingsView(onLogout: onLogout)
.tabItem {
Label(RosettaTab.settings.label, systemImage: RosettaTab.settings.icon)
}
.tag(RosettaTab.settings)
SearchView(onChatDetailVisibilityChange: { isPresented in
isSearchDetailPresented = isPresented
})
.tabItem {
Label(RosettaTab.search.label, systemImage: RosettaTab.search.icon)
}
.tag(RosettaTab.search)
}
.tint(RosettaColors.primaryBlue)
}
private var legacyTabView: some View {
ZStack(alignment: .bottom) { ZStack(alignment: .bottom) {
RosettaColors.Adaptive.background RosettaColors.Adaptive.background
.ignoresSafeArea() .ignoresSafeArea()
@@ -64,32 +34,35 @@ struct MainTabView: View {
RosettaTabBar( RosettaTabBar(
selectedTab: selectedTab, selectedTab: selectedTab,
onTabSelected: { tab in onTabSelected: { tab in
tabSwipeState = nil activatedTabs.insert(tab)
// Activate adjacent tabs for smooth paging
for t in RosettaTab.interactionOrder { activatedTabs.insert(t) }
withAnimation(.spring(response: 0.34, dampingFraction: 0.82)) { withAnimation(.spring(response: 0.34, dampingFraction: 0.82)) {
selectedTab = tab selectedTab = tab
} }
}, },
onSwipeStateChanged: { state in onSwipeStateChanged: { state in
tabSwipeState = state if let state {
}, // Activate all main tabs during drag for smooth paging
badges: tabBadges for tab in RosettaTab.interactionOrder {
activatedTabs.insert(tab)
}
dragFractionalIndex = state.fractionalIndex
} else {
withAnimation(.spring(response: 0.34, dampingFraction: 0.82)) {
dragFractionalIndex = nil
}
}
}
) )
.ignoresSafeArea(.keyboard) .ignoresSafeArea(.keyboard)
.transition(.move(edge: .bottom).combined(with: .opacity)) .transition(.move(edge: .bottom).combined(with: .opacity))
} }
} }
.onChange(of: isChatSearchActive) { _, isActive in
if isActive {
tabSwipeState = nil
}
}
} }
private var currentPageIndex: CGFloat { private var currentPageIndex: CGFloat {
if let tabSwipeState { CGFloat(selectedTab.interactionIndex)
return max(0, min(CGFloat(RosettaTab.interactionOrder.count - 1), tabSwipeState.fractionalIndex))
}
return CGFloat(selectedTab.interactionIndex)
} }
@ViewBuilder @ViewBuilder
@@ -97,6 +70,8 @@ struct MainTabView: View {
let width = max(1, availableSize.width) let width = max(1, availableSize.width)
let totalWidth = width * CGFloat(RosettaTab.interactionOrder.count) let totalWidth = width * CGFloat(RosettaTab.interactionOrder.count)
// Child views are in a separate HStack that does NOT read dragFractionalIndex,
// so they won't re-render during drag only the offset modifier updates.
HStack(spacing: 0) { HStack(spacing: 0) {
ForEach(RosettaTab.interactionOrder, id: \.self) { tab in ForEach(RosettaTab.interactionOrder, id: \.self) { tab in
tabView(for: tab) tabView(for: tab)
@@ -104,27 +79,30 @@ struct MainTabView: View {
} }
} }
.frame(width: totalWidth, alignment: .leading) .frame(width: totalWidth, alignment: .leading)
.offset(x: -currentPageIndex * width) .modifier(PagerOffsetModifier(
.animation(tabSwipeState == nil ? .spring(response: 0.34, dampingFraction: 0.82) : nil, value: currentPageIndex) effectiveIndex: dragFractionalIndex ?? currentPageIndex,
pageWidth: width,
isDragging: dragFractionalIndex != nil
))
.clipped() .clipped()
} }
@ViewBuilder @ViewBuilder
private func tabView(for tab: RosettaTab) -> some View { private func tabView(for tab: RosettaTab) -> some View {
switch tab { if activatedTabs.contains(tab) {
case .chats: switch tab {
ChatListView( case .chats:
isSearchActive: $isChatSearchActive, ChatListView(
onChatDetailVisibilityChange: { isPresented in isSearchActive: $isChatSearchActive,
isChatListDetailPresented = isPresented isDetailPresented: $isChatListDetailPresented
} )
) case .settings:
case .settings: SettingsView(onLogout: onLogout)
SettingsView(onLogout: onLogout) case .search:
case .search: SearchView(isDetailPresented: $isSearchDetailPresented)
SearchView(onChatDetailVisibilityChange: { isPresented in }
isSearchDetailPresented = isPresented } else {
}) RosettaColors.Adaptive.background
} }
} }
@@ -132,32 +110,27 @@ struct MainTabView: View {
isChatListDetailPresented || isSearchDetailPresented isChatListDetailPresented || isSearchDetailPresented
} }
private var tabBadges: [TabBadge] {
guard let chatUnreadBadge else {
return []
}
return [TabBadge(tab: .chats, text: chatUnreadBadge)]
}
private var chatUnreadBadge: String? {
let unread = DialogRepository.shared.sortedDialogs
.filter { !$0.isMuted }
.reduce(0) { $0 + $1.unreadCount }
if unread <= 0 {
return nil
}
return unread > 999 ? "\(unread / 1000)K" : "\(unread)"
}
} }
private extension View { // MARK: - Pager Offset Modifier
@ViewBuilder
func badgeIfNeeded(_ value: String?) -> some View { /// Isolates the offset/animation from child view identity so that
if let value { /// changing `effectiveIndex` only redraws the transform, not the child views.
badge(value) private struct PagerOffsetModifier: ViewModifier {
} else { let effectiveIndex: CGFloat
self let pageWidth: CGFloat
} let isDragging: Bool
@MainActor static var _bodyCount = 0
func body(content: Content) -> some View {
let _ = Self._bodyCount += 1
let _ = print("⬛ PagerOffset.body #\(Self._bodyCount) idx=\(effectiveIndex) w=\(pageWidth)")
content
.offset(x: -effectiveIndex * pageWidth)
.animation(
isDragging ? nil : .spring(response: 0.34, dampingFraction: 0.82),
value: effectiveIndex
)
} }
} }
@@ -195,7 +168,7 @@ struct PlaceholderTabView: View {
.foregroundStyle(RosettaColors.Adaptive.text) .foregroundStyle(RosettaColors.Adaptive.text)
} }
} }
.toolbarBackground(.ultraThinMaterial, for: .navigationBar) .toolbarBackground(RosettaColors.Adaptive.background, for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar) .toolbarBackground(.visible, for: .navigationBar)
} }
} }

View File

@@ -4,11 +4,14 @@ import SwiftUI
struct SettingsView: View { struct SettingsView: View {
var onLogout: (() -> Void)? var onLogout: (() -> Void)?
@State private var viewModel = SettingsViewModel() @StateObject private var viewModel = SettingsViewModel()
@State private var showCopiedToast = false @State private var showCopiedToast = false
@State private var showLogoutConfirmation = false @State private var showLogoutConfirmation = false
@MainActor static var _bodyCount = 0
var body: some View { var body: some View {
let _ = Self._bodyCount += 1
let _ = print("🟢 SettingsView.body #\(Self._bodyCount)")
NavigationStack { NavigationStack {
ScrollView { ScrollView {
VStack(spacing: 16) { VStack(spacing: 16) {
@@ -36,8 +39,9 @@ struct SettingsView: View {
.foregroundStyle(RosettaColors.primaryBlue) .foregroundStyle(RosettaColors.primaryBlue)
} }
} }
.toolbarBackground(.ultraThinMaterial, for: .navigationBar) .toolbarBackground(RosettaColors.Adaptive.background, for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar) .toolbarBackground(.visible, for: .navigationBar)
.task { viewModel.refresh() }
.alert("Log Out", isPresented: $showLogoutConfirmation) { .alert("Log Out", isPresented: $showLogoutConfirmation) {
Button("Cancel", role: .cancel) {} Button("Cancel", role: .cancel) {}
Button("Log Out", role: .destructive) { Button("Log Out", role: .destructive) {
@@ -212,7 +216,7 @@ struct SettingsView: View {
.foregroundStyle(RosettaColors.Adaptive.text) .foregroundStyle(RosettaColors.Adaptive.text)
.padding(.horizontal, 20) .padding(.horizontal, 20)
.padding(.vertical, 10) .padding(.vertical, 10)
.background(.ultraThinMaterial) .background(RosettaColors.adaptive(light: Color(hex: 0xF2F2F7), dark: Color(hex: 0x1C1C1E)))
.clipShape(Capsule()) .clipShape(Capsule())
.padding(.top, 60) .padding(.top, 60)
} }

View File

@@ -1,26 +1,26 @@
import Combine
import Foundation import Foundation
import Observation
import UIKit import UIKit
@Observable /// Settings view model with **cached** state.
///
/// Previously this was `@Observable` with computed properties that read
/// `ProtocolManager`, `SessionManager`, and `AccountManager` directly.
/// Because all tabs are pre-activated, those reads caused SettingsView
/// (inside a NavigationStack) to re-render 6+ times during handshake,
/// producing "Update NavigationRequestObserver tried to update multiple
/// times per frame" app freeze.
///
/// Now uses `ObservableObject` + `@Published` stored properties.
/// State is refreshed explicitly via `refresh()`.
@MainActor @MainActor
final class SettingsViewModel { final class SettingsViewModel: ObservableObject {
var displayName: String { @Published private(set) var displayName: String = ""
SessionManager.shared.displayName.isEmpty @Published private(set) var username: String = ""
? (AccountManager.shared.currentAccount?.displayName ?? "") @Published private(set) var publicKey: String = ""
: SessionManager.shared.displayName @Published private(set) var connectionStatus: String = "Disconnected"
} @Published private(set) var isConnected: Bool = false
var username: String {
SessionManager.shared.username.isEmpty
? (AccountManager.shared.currentAccount?.username ?? "")
: SessionManager.shared.username
}
var publicKey: String {
AccountManager.shared.currentAccount?.publicKey ?? ""
}
var initials: String { var initials: String {
RosettaColors.initials(name: displayName, publicKey: publicKey) RosettaColors.initials(name: displayName, publicKey: publicKey)
@@ -30,24 +30,34 @@ final class SettingsViewModel {
RosettaColors.avatarColorIndex(for: publicKey) RosettaColors.avatarColorIndex(for: publicKey)
} }
var connectionStatus: String { /// Snapshot current state from singletons. Call from `.task {}` or `.onAppear`.
switch ProtocolManager.shared.connectionState { func refresh() {
case .disconnected: return "Disconnected" let session = SessionManager.shared
case .connecting: return "Connecting..." let account = AccountManager.shared.currentAccount
case .connected: return "Connected"
case .handshaking: return "Authenticating..." displayName = session.displayName.isEmpty
case .deviceVerificationRequired: return "Device Verification Required" ? (account?.displayName ?? "")
case .authenticated: return "Online" : session.displayName
username = session.username.isEmpty
? (account?.username ?? "")
: session.username
publicKey = account?.publicKey ?? ""
let state = ProtocolManager.shared.connectionState
isConnected = state == .authenticated
switch state {
case .disconnected: connectionStatus = "Disconnected"
case .connecting: connectionStatus = "Connecting..."
case .connected: connectionStatus = "Connected"
case .handshaking: connectionStatus = "Authenticating..."
case .deviceVerificationRequired: connectionStatus = "Device Verification Required"
case .authenticated: connectionStatus = "Online"
} }
} }
var isConnected: Bool {
ProtocolManager.shared.connectionState == .authenticated
}
func copyPublicKey() { func copyPublicKey() {
#if canImport(UIKit)
UIPasteboard.general.string = publicKey UIPasteboard.general.string = publicKey
#endif
} }
} }

View File

@@ -16,7 +16,7 @@ private enum AppState {
struct RosettaApp: App { struct RosettaApp: App {
init() { init() {
UIWindow.appearance().backgroundColor = .systemBackground UIWindow.appearance().backgroundColor = .black
// Detect fresh install: UserDefaults are wiped on uninstall, Keychain is not. // Detect fresh install: UserDefaults are wiped on uninstall, Keychain is not.
// If this is the first launch after install, clear any stale Keychain data. // If this is the first launch after install, clear any stale Keychain data.
@@ -46,8 +46,11 @@ struct RosettaApp: App {
} }
} }
@MainActor static var _bodyCount = 0
@ViewBuilder @ViewBuilder
private var rootView: some View { private var rootView: some View {
let _ = Self._bodyCount += 1
let _ = print("⭐ RosettaApp.rootView #\(Self._bodyCount) state=\(appState)")
switch appState { switch appState {
case .splash: case .splash:
SplashView { SplashView {