From e26d94b2688b1d9983c1d7e66c3f97dd4961d058 Mon Sep 17 00:00:00 2001 From: senseiGai Date: Sun, 8 Mar 2026 05:14:54 +0500 Subject: [PATCH] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B1=D0=B5=D1=81=D0=BA=D0=BE=D0=BD?= =?UTF-8?q?=D0=B5=D1=87=D0=BD=D0=BE=D0=B3=D0=BE=20=D1=80=D0=B5=D0=BD=D0=B4?= =?UTF-8?q?=D0=B5=D1=80-=D1=86=D0=B8=D0=BA=D0=BB=D0=B0=20SearchView=20?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=BE=D0=B8=D1=81=D0=BA=20=D0=BF=D0=BE=20=D0=BF?= =?UTF-8?q?=D1=83=D0=B1=D0=BB=D0=B8=D1=87=D0=BD=D0=BE=D0=BC=D1=83=20=D0=BA?= =?UTF-8?q?=D0=BB=D1=8E=D1=87=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../Chats/ChatDetail/ChatDetailView.swift | 101 ++++---- .../Chats/ChatList/ChatListView.swift | 243 +++++++++++------- .../Chats/Search/SearchResultsSection.swift | 4 +- .../Features/Chats/Search/SearchView.swift | 62 +++-- .../Chats/Search/SearchViewModel.swift | 89 +++++-- Rosetta/Features/MainTabView.swift | 163 +++++------- Rosetta/Features/Settings/SettingsView.swift | 10 +- .../Features/Settings/SettingsViewModel.swift | 74 +++--- Rosetta/RosettaApp.swift | 5 +- 9 files changed, 442 insertions(+), 309 deletions(-) diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift index 10a3b3b..a284f5c 100644 --- a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift @@ -6,10 +6,10 @@ struct ChatDetailView: View { @Environment(\.dismiss) private var dismiss @ObservedObject private var messageRepository = MessageRepository.shared - @State private var dialogRepository = DialogRepository.shared @State private var messageText = "" @State private var sendError: String? + @State private var isViewActive = false @FocusState private var isInputFocused: Bool private var currentPublicKey: String { @@ -17,7 +17,7 @@ struct ChatDetailView: View { } private var dialog: Dialog? { - dialogRepository.dialogs[route.publicKey] + DialogRepository.shared.dialogs[route.publicKey] } private var messages: [ChatMessage] { @@ -106,18 +106,21 @@ struct ChatDetailView: View { .toolbar { chatDetailToolbar } // твой header тут .toolbar(.hidden, for: .tabBar) .task { + isViewActive = true // Request user info (non-mutating, won't trigger list rebuild) requestUserInfoIfNeeded() // Delay ALL dialog mutations to let navigation transition complete. // Without this, DialogRepository update rebuilds ChatListView's ForEach // mid-navigation, recreating the NavigationLink and canceling the push. try? await Task.sleep(for: .milliseconds(600)) + guard isViewActive else { return } activateDialog() markDialogAsRead() // Subscribe to opponent's online status (Android parity) — only after settled SessionManager.shared.subscribeToOnlineStatus(publicKey: route.publicKey) } .onDisappear { + isViewActive = false messageRepository.setDialogActive(route.publicKey, isActive: false) } } @@ -306,16 +309,20 @@ private extension ChatDetailView { try? await Task.sleep(for: .milliseconds(120)) 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 scrollToBottom(proxy: proxy, animated: true) - markDialogAsRead() + if isViewActive { + markDialogAsRead() + } } .onChange(of: isInputFocused) { _, focused in guard focused else { return } Task { @MainActor in - try? await Task.sleep(for: .milliseconds(80)) + try? await Task.sleep(nanoseconds: 80_000_000) scrollToBottom(proxy: proxy, animated: true) } } @@ -331,28 +338,33 @@ private extension ChatDetailView { let outgoing = message.isFromMe(myPublicKey: currentPublicKey) let messageText = message.text.isEmpty ? " " : message.text - // Text determines bubble width; timestamp overlays at bottom-trailing. - // minWidth ensures the bubble is wide enough for the timestamp row. + // Telegram-style compact bubble: inline time+status at bottom-trailing. + // Right padding reserves space for the timestamp overlay (64pt outgoing, 48pt incoming). Text(messageText) - .font(.system(size: 16, weight: .regular)) + .font(.system(size: 17, weight: .regular)) + .tracking(-0.43) .foregroundStyle(outgoing ? Color.white : RosettaColors.Adaptive.text) .multilineTextAlignment(.leading) .lineSpacing(0) .fixedSize(horizontal: false, vertical: true) - .padding(.horizontal, 14) - .padding(.top, 8) - .padding(.bottom, 22) - .frame(minWidth: outgoing ? 90 : 70, alignment: .leading) + .padding(.leading, 11) + .padding(.trailing, outgoing ? 64 : 48) + .padding(.vertical, 5) + .frame(minWidth: outgoing ? 86 : 66, alignment: .leading) .overlay(alignment: .bottomTrailing) { - HStack(spacing: 4) { + HStack(spacing: 3) { Text(messageTime(message.timestamp)) - .font(.system(size: 12, weight: .regular)) - .foregroundStyle(outgoing ? Color.white.opacity(0.72) : RosettaColors.Adaptive.textSecondary) + .font(.system(size: 11, weight: .regular)) + .foregroundStyle( + outgoing + ? Color.white.opacity(0.55) + : RosettaColors.Adaptive.textSecondary.opacity(0.6) + ) if outgoing { deliveryIndicator(message.deliveryStatus) } } - .padding(.trailing, 14) - .padding(.bottom, 6) + .padding(.trailing, 11) + .padding(.bottom, 5) } .background { bubbleBackground(outgoing: outgoing, isTailVisible: isTailVisible) } .frame(maxWidth: maxBubbleWidth, alignment: outgoing ? .trailing : .leading) @@ -529,8 +541,8 @@ private extension ChatDetailView { @ViewBuilder func bubbleBackground(outgoing: Bool, isTailVisible: Bool) -> some View { - let nearRadius: CGFloat = isTailVisible ? 6 : 17 - let bubbleRadius: CGFloat = 17 + let nearRadius: CGFloat = isTailVisible ? 8 : 18 + let bubbleRadius: CGFloat = 18 let fill = outgoing ? RosettaColors.figmaBlue : incomingBubbleFill if #available(iOS 17.0, *) { UnevenRoundedRectangle( @@ -561,34 +573,21 @@ private extension ChatDetailView { strokeOpacity: Double = 0.18, strokeColor: Color = RosettaColors.Adaptive.border ) -> some View { - if #available(iOS 26.0, *) { - switch shape { - case .capsule: - Capsule().fill(.clear).glassEffect(.regular, in: .capsule) - case .circle: - Circle().fill(.clear).glassEffect(.regular, in: .circle) - case let .rounded(radius): - RoundedRectangle(cornerRadius: radius, style: .continuous) - .fill(.clear) - .glassEffect(.regular, in: RoundedRectangle(cornerRadius: radius, style: .continuous)) - } - } else { - let border = strokeColor.opacity(max(0.28, strokeOpacity)) - switch shape { - case .capsule: - 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)) - } + let border = strokeColor.opacity(max(0.28, strokeOpacity)) + switch shape { + case .capsule: + Capsule() + .fill(RosettaColors.adaptive(light: Color(hex: 0xF2F2F7), dark: Color(hex: 0x1C1C1E))) + .overlay(Capsule().stroke(border, lineWidth: 0.8)) + case .circle: + Circle() + .fill(RosettaColors.adaptive(light: Color(hex: 0xF2F2F7), dark: Color(hex: 0x1C1C1E))) + .overlay(Circle().stroke(border, lineWidth: 0.8)) + case let .rounded(radius): + let rounded = RoundedRectangle(cornerRadius: radius, style: .continuous) + rounded + .fill(RosettaColors.adaptive(light: Color(hex: 0xF2F2F7), dark: Color(hex: 0x1C1C1E))) + .overlay(rounded.stroke(border, lineWidth: 0.8)) } } @@ -625,12 +624,12 @@ private extension ChatDetailView { Image(systemName: "checkmark").offset(x: 3) Image(systemName: "checkmark") } - .font(.system(size: 10.5, weight: .semibold)) + .font(.system(size: 9.5, weight: .semibold)) .foregroundStyle(deliveryTint(status)) - .frame(width: 13, alignment: .trailing) + .frame(width: 12, alignment: .trailing) default: Image(systemName: deliveryIcon(status)) - .font(.system(size: 11, weight: .semibold)) + .font(.system(size: 10, weight: .semibold)) .foregroundStyle(deliveryTint(status)) } } @@ -670,7 +669,7 @@ private extension ChatDetailView { func activateDialog() { // Only update existing dialogs; don't create ghost entries from search. // 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( opponentKey: route.publicKey, title: route.title, diff --git a/Rosetta/Features/Chats/ChatList/ChatListView.swift b/Rosetta/Features/Chats/ChatList/ChatListView.swift index 8d4ea91..9602de3 100644 --- a/Rosetta/Features/Chats/ChatList/ChatListView.swift +++ b/Rosetta/Features/Chats/ChatList/ChatListView.swift @@ -1,16 +1,40 @@ +import Combine import SwiftUI +// MARK: - Navigation State (survives parent re-renders) + +@MainActor +final class ChatListNavigationState: ObservableObject { + @Published var path: [ChatRoute] = [] +} + // 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 { @Binding var isSearchActive: Bool - var onChatDetailVisibilityChange: ((Bool) -> Void)? = nil + @Binding var isDetailPresented: Bool @StateObject private var viewModel = ChatListViewModel() + @StateObject private var navigationState = ChatListNavigationState() @State private var searchText = "" - @State private var navigationPath: [ChatRoute] = [] + @MainActor static var _bodyCount = 0 var body: some View { - NavigationStack(path: $navigationPath) { + let _ = Self._bodyCount += 1 + let _ = print("🟡 ChatListView.body #\(Self._bodyCount)") + NavigationStack(path: $navigationState.path) { ZStack { RosettaColors.Adaptive.background .ignoresSafeArea() @@ -23,7 +47,7 @@ struct ChatListView: View { onOpenDialog: { route in isSearchActive = false searchText = "" - navigationPath.append(route) + navigationState.path.append(route) } ) } else { @@ -46,16 +70,16 @@ struct ChatListView: View { .navigationDestination(for: ChatRoute.self) { route in ChatDetailView( route: route, - onPresentedChange: { isPresented in - onChatDetailVisibilityChange?(isPresented) + onPresentedChange: { presented in + isDetailPresented = presented } ) } .onAppear { - onChatDetailVisibilityChange?(!navigationPath.isEmpty) + isDetailPresented = !navigationState.path.isEmpty } - .onChange(of: navigationPath) { _, newPath in - onChatDetailVisibilityChange?(!newPath.isEmpty) + .onChange(of: navigationState.path) { _, newPath in + isDetailPresented = !newPath.isEmpty } } .tint(RosettaColors.figmaBlue) @@ -68,36 +92,129 @@ private extension ChatListView { @ViewBuilder var normalContent: some View { VStack(spacing: 0) { - deviceVerificationBanners + // Isolated view — reads ProtocolManager (@Observable) without + // polluting ChatListView's observation scope. + DeviceVerificationBannersContainer() - if viewModel.filteredDialogs.isEmpty && !viewModel.isLoading { - ChatEmptyStateView(searchText: "") - } else { - dialogList - } - } - } - - @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) } + // Isolated view — reads DialogRepository (@Observable) via viewModel + // without polluting ChatListView's observation scope. + ChatListDialogContent( + viewModel: viewModel, + navigationState: navigationState ) } } +} - 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 { if viewModel.isLoading { ForEach(0..<8, id: \.self) { _ in @@ -128,9 +245,9 @@ private extension ChatListView { .scrollDismissesKeyboard(.immediately) } - func chatRow(_ dialog: Dialog) -> some View { + private func chatRow(_ dialog: Dialog) -> some View { Button { - navigationPath.append(ChatRoute(dialog: dialog)) + navigationState.path.append(ChatRoute(dialog: dialog)) } label: { 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 /// 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)) } diff --git a/Rosetta/Features/Chats/Search/SearchResultsSection.swift b/Rosetta/Features/Chats/Search/SearchResultsSection.swift index 1051ca3..1b86023 100644 --- a/Rosetta/Features/Chats/Search/SearchResultsSection.swift +++ b/Rosetta/Features/Chats/Search/SearchResultsSection.swift @@ -5,6 +5,7 @@ import SwiftUI struct SearchResultsSection: View { let isSearching: Bool let searchResults: [SearchUser] + let currentPublicKey: String var onSelectUser: (SearchUser) -> Void var body: some View { @@ -58,7 +59,7 @@ private extension SearchResultsSection { private extension SearchResultsSection { 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 colorIdx = RosettaColors.avatarColorIndex(for: user.publicKey) @@ -118,6 +119,7 @@ private extension SearchResultsSection { SearchResultsSection( isSearching: false, searchResults: [], + currentPublicKey: "", onSelectUser: { _ in } ) } diff --git a/Rosetta/Features/Chats/Search/SearchView.swift b/Rosetta/Features/Chats/Search/SearchView.swift index b60ffe8..3d6f73f 100644 --- a/Rosetta/Features/Chats/Search/SearchView.swift +++ b/Rosetta/Features/Chats/Search/SearchView.swift @@ -3,12 +3,15 @@ import SwiftUI // MARK: - SearchView struct SearchView: View { - var onChatDetailVisibilityChange: ((Bool) -> Void)? = nil - @State private var viewModel = SearchViewModel() + @Binding var isDetailPresented: Bool + @StateObject private var viewModel = SearchViewModel() @State private var searchText = "" @State private var navigationPath: [ChatRoute] = [] + @MainActor static var _bodyCount = 0 var body: some View { + let _ = Self._bodyCount += 1 + let _ = print("🔵 SearchView.body #\(Self._bodyCount)") NavigationStack(path: $navigationPath) { ZStack(alignment: .bottom) { RosettaColors.Adaptive.background @@ -17,8 +20,11 @@ struct SearchView: View { ScrollView { VStack(spacing: 0) { if searchText.isEmpty { - favoriteContactsRow - recentSection + FavoriteContactsRow(navigationPath: $navigationPath) + RecentSection( + viewModel: viewModel, + navigationPath: $navigationPath + ) } else { searchResultsContent } @@ -37,15 +43,15 @@ struct SearchView: View { ChatDetailView( route: route, onPresentedChange: { isPresented in - onChatDetailVisibilityChange?(isPresented) + isDetailPresented = isPresented } ) } .onAppear { - onChatDetailVisibilityChange?(!navigationPath.isEmpty) + isDetailPresented = !navigationPath.isEmpty } .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 { - @ViewBuilder - var favoriteContactsRow: some View { +/// Isolated child view so that `DialogRepository.shared.sortedDialogs` observation +/// does NOT propagate to `SearchView`'s NavigationStack. +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) if !dialogs.isEmpty { 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 { - @ViewBuilder - var recentSection: some View { +/// Isolated child view so that `SessionManager.shared.currentPublicKey` observation +/// does NOT propagate to `SearchView`'s NavigationStack. +private struct RecentSection: View { + @ObservedObject var viewModel: SearchViewModel + @Binding var navigationPath: [ChatRoute] + @MainActor static var _bodyCount = 0 + + var body: some View { + let _ = Self._bodyCount += 1 + let _ = print("🟤 RecentSection.body #\(Self._bodyCount)") if viewModel.recentSearches.isEmpty { emptyState } else { VStack(spacing: 0) { - // Section header HStack { Text("RECENT") .font(.system(size: 13)) @@ -198,7 +216,6 @@ private extension SearchView { .padding(.top, 8) .padding(.bottom, 6) - // Recent items ForEach(viewModel.recentSearches, id: \.publicKey) { user in recentRow(user) } @@ -206,7 +223,7 @@ private extension SearchView { } } - var emptyState: some View { + private var emptyState: some View { VStack(spacing: 16) { LottieView( animationName: "search", @@ -228,8 +245,9 @@ private extension SearchView { .frame(maxWidth: .infinity) } - func recentRow(_ user: RecentSearch) -> some View { - let isSelf = user.publicKey == SessionManager.shared.currentPublicKey + private func recentRow(_ user: RecentSearch) -> some View { + let currentPK = SessionManager.shared.currentPublicKey + let isSelf = user.publicKey == currentPK let initials = isSelf ? "S" : RosettaColors.initials(name: user.title, publicKey: user.publicKey) let colorIdx = RosettaColors.avatarColorIndex(for: user.publicKey) @@ -245,7 +263,6 @@ private extension SearchView { isSavedMessages: isSelf ) - // Close button to remove from recent Button { viewModel.removeRecentSearch(publicKey: user.publicKey) } label: { @@ -288,6 +305,7 @@ private extension SearchView { SearchResultsSection( isSearching: viewModel.isSearching, searchResults: viewModel.searchResults, + currentPublicKey: SessionManager.shared.currentPublicKey, onSelectUser: { user in viewModel.addToRecent(user) navigationPath.append(ChatRoute(user: user)) @@ -299,5 +317,5 @@ private extension SearchView { // MARK: - Preview #Preview { - SearchView(onChatDetailVisibilityChange: nil) + SearchView(isDetailPresented: .constant(false)) } diff --git a/Rosetta/Features/Chats/Search/SearchViewModel.swift b/Rosetta/Features/Chats/Search/SearchViewModel.swift index d9aa876..b48f57b 100644 --- a/Rosetta/Features/Chats/Search/SearchViewModel.swift +++ b/Rosetta/Features/Chats/Search/SearchViewModel.swift @@ -4,19 +4,26 @@ import os // 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 -final class SearchViewModel { +final class SearchViewModel: ObservableObject { private static let logger = Logger(subsystem: "com.rosetta.messenger", category: "Search") // MARK: - State - var searchQuery = "" + @Published var searchQuery = "" - private(set) var searchResults: [SearchUser] = [] - private(set) var isSearching = false - private(set) var recentSearches: [RecentSearch] = [] + @Published private(set) var searchResults: [SearchUser] = [] + @Published private(set) var isSearching = false + @Published private(set) var recentSearches: [RecentSearch] = [] private var searchTask: Task? private var lastSearchedText = "" @@ -51,34 +58,28 @@ final class SearchViewModel { } if trimmed == lastSearchedText { - return } isSearching = true - // Debounce 1 second (like Android) 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) guard !currentQuery.isEmpty, currentQuery == trimmed else { - return } let connState = ProtocolManager.shared.connectionState let hash = SessionManager.shared.privateKeyHash ?? ProtocolManager.shared.privateHash - guard connState == .authenticated, let hash else { - self.isSearching = false return } @@ -112,15 +113,27 @@ final class SearchViewModel { searchHandlerToken = ProtocolManager.shared.addSearchResultHandler { [weak self] packet in DispatchQueue.main.async { [weak self] in 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 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 - // Update dialog info from results + // Update dialog info from server results for user in packet.users { DialogRepository.shared.updateUserInfo( 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 { input.replacingOccurrences(of: "@", with: "") .trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/Rosetta/Features/MainTabView.swift b/Rosetta/Features/MainTabView.swift index 4e7df6c..88db248 100644 --- a/Rosetta/Features/MainTabView.swift +++ b/Rosetta/Features/MainTabView.swift @@ -5,53 +5,23 @@ struct MainTabView: View { var onLogout: (() -> Void)? @State private var selectedTab: RosettaTab = .chats @State private var isChatSearchActive = false - @State private var tabSwipeState: TabBarSwipeState? @State private var isChatListDetailPresented = 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 = 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 { - Group { - if #available(iOS 26.0, *) { - systemTabView - } else { - legacyTabView - } - } + let _ = Self._bodyCount += 1 + let _ = print("🔴 MainTabView.body #\(Self._bodyCount) search=\(isChatSearchActive) chatDetail=\(isChatListDetailPresented) searchDetail=\(isSearchDetailPresented)") + mainTabView } + @MainActor static var _bodyCount = 0 - @available(iOS 26.0, *) - 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 { + private var mainTabView: some View { ZStack(alignment: .bottom) { RosettaColors.Adaptive.background .ignoresSafeArea() @@ -64,32 +34,35 @@ struct MainTabView: View { RosettaTabBar( selectedTab: selectedTab, 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)) { selectedTab = tab } }, onSwipeStateChanged: { state in - tabSwipeState = state - }, - badges: tabBadges + if let state { + // Activate all main tabs during drag for smooth paging + for tab in RosettaTab.interactionOrder { + activatedTabs.insert(tab) + } + dragFractionalIndex = state.fractionalIndex + } else { + withAnimation(.spring(response: 0.34, dampingFraction: 0.82)) { + dragFractionalIndex = nil + } + } + } ) .ignoresSafeArea(.keyboard) .transition(.move(edge: .bottom).combined(with: .opacity)) } } - .onChange(of: isChatSearchActive) { _, isActive in - if isActive { - tabSwipeState = nil - } - } } private var currentPageIndex: CGFloat { - if let tabSwipeState { - return max(0, min(CGFloat(RosettaTab.interactionOrder.count - 1), tabSwipeState.fractionalIndex)) - } - return CGFloat(selectedTab.interactionIndex) + CGFloat(selectedTab.interactionIndex) } @ViewBuilder @@ -97,6 +70,8 @@ struct MainTabView: View { let width = max(1, availableSize.width) 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) { ForEach(RosettaTab.interactionOrder, id: \.self) { tab in tabView(for: tab) @@ -104,27 +79,30 @@ struct MainTabView: View { } } .frame(width: totalWidth, alignment: .leading) - .offset(x: -currentPageIndex * width) - .animation(tabSwipeState == nil ? .spring(response: 0.34, dampingFraction: 0.82) : nil, value: currentPageIndex) + .modifier(PagerOffsetModifier( + effectiveIndex: dragFractionalIndex ?? currentPageIndex, + pageWidth: width, + isDragging: dragFractionalIndex != nil + )) .clipped() } @ViewBuilder private func tabView(for tab: RosettaTab) -> some View { - switch tab { - case .chats: - ChatListView( - isSearchActive: $isChatSearchActive, - onChatDetailVisibilityChange: { isPresented in - isChatListDetailPresented = isPresented - } - ) - case .settings: - SettingsView(onLogout: onLogout) - case .search: - SearchView(onChatDetailVisibilityChange: { isPresented in - isSearchDetailPresented = isPresented - }) + if activatedTabs.contains(tab) { + switch tab { + case .chats: + ChatListView( + isSearchActive: $isChatSearchActive, + isDetailPresented: $isChatListDetailPresented + ) + case .settings: + SettingsView(onLogout: onLogout) + case .search: + SearchView(isDetailPresented: $isSearchDetailPresented) + } + } else { + RosettaColors.Adaptive.background } } @@ -132,32 +110,27 @@ struct MainTabView: View { 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 { - @ViewBuilder - func badgeIfNeeded(_ value: String?) -> some View { - if let value { - badge(value) - } else { - self - } +// MARK: - Pager Offset Modifier + +/// Isolates the offset/animation from child view identity so that +/// changing `effectiveIndex` only redraws the transform, not the child views. +private struct PagerOffsetModifier: ViewModifier { + let effectiveIndex: CGFloat + 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) } } - .toolbarBackground(.ultraThinMaterial, for: .navigationBar) + .toolbarBackground(RosettaColors.Adaptive.background, for: .navigationBar) .toolbarBackground(.visible, for: .navigationBar) } } diff --git a/Rosetta/Features/Settings/SettingsView.swift b/Rosetta/Features/Settings/SettingsView.swift index 0ce6414..6dda0f5 100644 --- a/Rosetta/Features/Settings/SettingsView.swift +++ b/Rosetta/Features/Settings/SettingsView.swift @@ -4,11 +4,14 @@ import SwiftUI struct SettingsView: View { var onLogout: (() -> Void)? - @State private var viewModel = SettingsViewModel() + @StateObject private var viewModel = SettingsViewModel() @State private var showCopiedToast = false @State private var showLogoutConfirmation = false + @MainActor static var _bodyCount = 0 var body: some View { + let _ = Self._bodyCount += 1 + let _ = print("🟢 SettingsView.body #\(Self._bodyCount)") NavigationStack { ScrollView { VStack(spacing: 16) { @@ -36,8 +39,9 @@ struct SettingsView: View { .foregroundStyle(RosettaColors.primaryBlue) } } - .toolbarBackground(.ultraThinMaterial, for: .navigationBar) + .toolbarBackground(RosettaColors.Adaptive.background, for: .navigationBar) .toolbarBackground(.visible, for: .navigationBar) + .task { viewModel.refresh() } .alert("Log Out", isPresented: $showLogoutConfirmation) { Button("Cancel", role: .cancel) {} Button("Log Out", role: .destructive) { @@ -212,7 +216,7 @@ struct SettingsView: View { .foregroundStyle(RosettaColors.Adaptive.text) .padding(.horizontal, 20) .padding(.vertical, 10) - .background(.ultraThinMaterial) + .background(RosettaColors.adaptive(light: Color(hex: 0xF2F2F7), dark: Color(hex: 0x1C1C1E))) .clipShape(Capsule()) .padding(.top, 60) } diff --git a/Rosetta/Features/Settings/SettingsViewModel.swift b/Rosetta/Features/Settings/SettingsViewModel.swift index 6e92dea..2ece0b6 100644 --- a/Rosetta/Features/Settings/SettingsViewModel.swift +++ b/Rosetta/Features/Settings/SettingsViewModel.swift @@ -1,26 +1,26 @@ +import Combine import Foundation -import Observation 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 -final class SettingsViewModel { +final class SettingsViewModel: ObservableObject { - var displayName: String { - SessionManager.shared.displayName.isEmpty - ? (AccountManager.shared.currentAccount?.displayName ?? "") - : SessionManager.shared.displayName - } - - var username: String { - SessionManager.shared.username.isEmpty - ? (AccountManager.shared.currentAccount?.username ?? "") - : SessionManager.shared.username - } - - var publicKey: String { - AccountManager.shared.currentAccount?.publicKey ?? "" - } + @Published private(set) var displayName: String = "" + @Published private(set) var username: String = "" + @Published private(set) var publicKey: String = "" + @Published private(set) var connectionStatus: String = "Disconnected" + @Published private(set) var isConnected: Bool = false var initials: String { RosettaColors.initials(name: displayName, publicKey: publicKey) @@ -30,24 +30,34 @@ final class SettingsViewModel { RosettaColors.avatarColorIndex(for: publicKey) } - var connectionStatus: String { - switch ProtocolManager.shared.connectionState { - case .disconnected: return "Disconnected" - case .connecting: return "Connecting..." - case .connected: return "Connected" - case .handshaking: return "Authenticating..." - case .deviceVerificationRequired: return "Device Verification Required" - case .authenticated: return "Online" + /// Snapshot current state from singletons. Call from `.task {}` or `.onAppear`. + func refresh() { + let session = SessionManager.shared + let account = AccountManager.shared.currentAccount + + displayName = session.displayName.isEmpty + ? (account?.displayName ?? "") + : 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() { - #if canImport(UIKit) UIPasteboard.general.string = publicKey - #endif } } diff --git a/Rosetta/RosettaApp.swift b/Rosetta/RosettaApp.swift index cccc235..5fb0593 100644 --- a/Rosetta/RosettaApp.swift +++ b/Rosetta/RosettaApp.swift @@ -16,7 +16,7 @@ private enum AppState { struct RosettaApp: App { init() { - UIWindow.appearance().backgroundColor = .systemBackground + UIWindow.appearance().backgroundColor = .black // Detect fresh install: UserDefaults are wiped on uninstall, Keychain is not. // 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 private var rootView: some View { + let _ = Self._bodyCount += 1 + let _ = print("⭐ RosettaApp.rootView #\(Self._bodyCount) state=\(appState)") switch appState { case .splash: SplashView {