Исправление бесконечного рендер-цикла 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:
@@ -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,
|
||||
|
||||
@@ -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)) }
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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<Void, Never>?
|
||||
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)
|
||||
|
||||
@@ -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<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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user