Исправление бесконечного рендер-цикла 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
@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,