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