Исправление расширения поля пароля при переключении видимости: перенос toggle в UIKit

This commit is contained in:
2026-03-12 04:01:21 +05:00
parent dc8e179c10
commit 70deaaf7f7
23 changed files with 706 additions and 285 deletions

View File

@@ -5,11 +5,18 @@ struct ChatDetailView: View {
var onPresentedChange: ((Bool) -> Void)? = nil
@Environment(\.dismiss) private var dismiss
@ObservedObject private var messageRepository = MessageRepository.shared
@StateObject private var viewModel: ChatDetailViewModel
init(route: ChatRoute, onPresentedChange: ((Bool) -> Void)? = nil) {
self.route = route
self.onPresentedChange = onPresentedChange
_viewModel = StateObject(wrappedValue: ChatDetailViewModel(dialogKey: route.publicKey))
}
@State private var messageText = ""
@State private var sendError: String?
@State private var isViewActive = false
// markReadTask removed read receipts no longer sent from .onChange(of: messages.count)
@FocusState private var isInputFocused: Bool
private var currentPublicKey: String {
@@ -21,11 +28,11 @@ struct ChatDetailView: View {
}
private var messages: [ChatMessage] {
messageRepository.messages(for: route.publicKey)
viewModel.messages
}
private var isTyping: Bool {
messageRepository.isTyping(dialogKey: route.publicKey)
viewModel.isTyping
}
private var titleText: String {
@@ -45,7 +52,6 @@ struct ChatDetailView: View {
private var subtitleText: String {
if route.isSavedMessages { return "" }
if ProtocolManager.shared.connectionState != .authenticated { return "connecting..." }
if isTyping { return "typing..." }
if let dialog, dialog.isOnline { return "online" }
return "offline"
@@ -74,9 +80,7 @@ struct ChatDetailView: View {
private var sendButtonWidth: CGFloat { 38 }
private var sendButtonHeight: CGFloat { 36 }
private var composerTrailingPadding: CGFloat {
isInputFocused ? 16 : 28
}
private var composerTrailingPadding: CGFloat { 16 }
private var composerAnimation: Animation {
.spring(response: 0.28, dampingFraction: 0.9)
@@ -125,7 +129,7 @@ struct ChatDetailView: View {
}
.onDisappear {
isViewActive = false
messageRepository.setDialogActive(route.publicKey, isActive: false)
MessageRepository.shared.setDialogActive(route.publicKey, isActive: false)
}
}
}
@@ -319,20 +323,25 @@ private extension ChatDetailView {
}
}
/// Cached tiled pattern color computed once, reused across renders
private static let cachedTiledColor: Color? = {
guard let uiImage = UIImage(named: "ChatBackground"),
let cgImage = uiImage.cgImage else { return nil }
let tileWidth: CGFloat = 200
let scaleFactor = uiImage.size.width / tileWidth
let scaledImage = UIImage(
cgImage: cgImage,
scale: uiImage.scale * scaleFactor,
orientation: .up
)
return Color(uiColor: UIColor(patternImage: scaledImage))
}()
/// Tiled chat background with properly scaled tiles (200pt wide)
private var tiledChatBackground: some View {
Group {
if let uiImage = UIImage(named: "ChatBackground"),
let cgImage = uiImage.cgImage {
let tileWidth: CGFloat = 200
let scaleFactor = uiImage.size.width / tileWidth
let scaledImage = UIImage(
cgImage: cgImage,
scale: uiImage.scale * scaleFactor,
orientation: .up
)
Color(uiColor: UIColor(patternImage: scaledImage))
.opacity(0.18)
if let color = Self.cachedTiledColor {
color.opacity(0.18)
} else {
Color.clear
}
@@ -389,7 +398,8 @@ private extension ChatDetailView {
ScrollViewReader { proxy in
let scroll = ScrollView(.vertical, showsIndicators: false) {
LazyVStack(spacing: 0) {
ForEach(Array(messages.enumerated()), id: \.element.id) { index, message in
ForEach(messages.indices, id: \.self) { index in
let message = messages[index]
messageRow(
message,
maxBubbleWidth: maxBubbleWidth,
@@ -406,7 +416,7 @@ private extension ChatDetailView {
.padding(.top, messagesTopInset)
.padding(.bottom, 10)
}
.scrollDismissesKeyboard(.interactively)
.scrollDismissesKeyboard(.immediately)
.onTapGesture { isInputFocused = false }
.onAppear {
DispatchQueue.main.async { scrollToBottom(proxy: proxy, animated: false) }
@@ -420,16 +430,19 @@ private extension ChatDetailView {
}
.onChange(of: messages.count) { _, _ in
scrollToBottom(proxy: proxy, animated: true)
if isViewActive {
markDialogAsRead()
}
// Read receipts are NOT sent here SessionManager already sends
// 0x07 for each incoming message when dialog is active (shouldMarkRead).
// Sending again from .onChange caused duplicate packets (2-3× more than
// desktop), which may contribute to server RST disconnects.
// The initial read is handled in .task with 600ms delay.
}
.onChange(of: isInputFocused) { _, focused in
guard focused else { return }
// User tapped the input reset idle timer.
SessionManager.shared.recordUserInteraction()
// Delay matches keyboard animation (~250ms) so scroll happens after layout settles.
Task { @MainActor in
try? await Task.sleep(nanoseconds: 80_000_000)
try? await Task.sleep(nanoseconds: 300_000_000)
scrollToBottom(proxy: proxy, animated: true)
}
}
@@ -469,7 +482,13 @@ private extension ChatDetailView {
: RosettaColors.Adaptive.textSecondary.opacity(0.6)
)
if outgoing { deliveryIndicator(message.deliveryStatus) }
if outgoing {
if message.deliveryStatus == .error {
errorMenu(for: message)
} else {
deliveryIndicator(message.deliveryStatus)
}
}
}
.padding(.trailing, 11)
.padding(.bottom, 5)
@@ -545,50 +564,11 @@ private extension ChatDetailView {
.frame(height: 36, alignment: .center)
.overlay(alignment: .trailing) {
Button(action: sendCurrentMessage) {
ZStack {
TelegramVectorIcon(
pathData: TelegramIconPath.sendPlane,
viewBox: CGSize(width: 22, height: 19),
color: .white
)
.blendMode(.difference)
TelegramVectorIcon(
pathData: TelegramIconPath.sendPlane,
viewBox: CGSize(width: 22, height: 19),
color: .white
)
.blendMode(.saturation)
TelegramVectorIcon(
pathData: TelegramIconPath.sendPlane,
viewBox: CGSize(width: 22, height: 19),
color: .white
)
.blendMode(.overlay)
TelegramVectorIcon(
pathData: TelegramIconPath.sendPlane,
viewBox: CGSize(width: 22, height: 19),
color: .black
)
.blendMode(.overlay)
TelegramVectorIcon(
pathData: TelegramIconPath.sendPlane,
viewBox: CGSize(width: 22, height: 19),
color: .white
)
.blendMode(.overlay)
TelegramVectorIcon(
pathData: TelegramIconPath.sendPlane,
viewBox: CGSize(width: 22, height: 19),
color: .black
)
.blendMode(.overlay)
}
.compositingGroup()
TelegramVectorIcon(
pathData: TelegramIconPath.sendPlane,
viewBox: CGSize(width: 22, height: 19),
color: .white
)
.opacity(0.42 + (0.58 * sendButtonProgress))
.scaleEffect(0.72 + (0.28 * sendButtonProgress))
.frame(width: 22, height: 19)
@@ -644,10 +624,9 @@ private extension ChatDetailView {
.padding(.leading, 16)
.padding(.trailing, composerTrailingPadding)
.padding(.top, 4)
.padding(.bottom, isInputFocused ? 8 : 0)
.padding(.bottom, 4)
.animation(composerAnimation, value: canSend)
.animation(composerAnimation, value: shouldShowSendButton)
.animation(composerAnimation, value: isInputFocused)
}
.background {
if #available(iOS 26, *) {
@@ -796,6 +775,41 @@ private extension ChatDetailView {
}
}
@ViewBuilder
func errorMenu(for message: ChatMessage) -> some View {
Menu {
Button {
retryMessage(message)
} label: {
Label("Retry", systemImage: "arrow.clockwise")
}
Button(role: .destructive) {
removeMessage(message)
} label: {
Label("Remove", systemImage: "trash")
}
} label: {
Image(systemName: "exclamationmark.circle.fill")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(RosettaColors.error)
}
}
func retryMessage(_ message: ChatMessage) {
let text = message.text
let toKey = message.toPublicKey
MessageRepository.shared.deleteMessage(id: message.id)
DialogRepository.shared.reconcileAfterMessageDelete(opponentKey: toKey)
Task {
try? await SessionManager.shared.sendMessage(text: text, toPublicKey: toKey)
}
}
func removeMessage(_ message: ChatMessage) {
MessageRepository.shared.deleteMessage(id: message.id)
DialogRepository.shared.reconcileAfterMessageDelete(opponentKey: message.toPublicKey)
}
func messageTime(_ timestamp: Int64) -> String {
Self.timeFormatter.string(from: Date(timeIntervalSince1970: Double(timestamp) / 1000))
}
@@ -829,12 +843,12 @@ private extension ChatDetailView {
myPublicKey: currentPublicKey
)
}
messageRepository.setDialogActive(route.publicKey, isActive: true)
MessageRepository.shared.setDialogActive(route.publicKey, isActive: true)
}
func markDialogAsRead() {
DialogRepository.shared.markAsRead(opponentKey: route.publicKey)
messageRepository.markIncomingAsRead(opponentKey: route.publicKey, myPublicKey: currentPublicKey)
MessageRepository.shared.markIncomingAsRead(opponentKey: route.publicKey, myPublicKey: currentPublicKey)
SessionManager.shared.sendReadReceipt(toPublicKey: route.publicKey)
}

View File

@@ -0,0 +1,65 @@
import Foundation
import Combine
/// Per-dialog observation isolation for ChatDetailView.
///
/// Instead of `@ObservedObject messageRepository` (which re-renders on ANY dialog change),
/// this ViewModel subscribes only to the specific dialog's messages via Combine pipeline
/// with `removeDuplicates()`. The view re-renders ONLY when its own dialog's data changes.
@MainActor
final class ChatDetailViewModel: ObservableObject {
let dialogKey: String
@Published private(set) var messages: [ChatMessage] = []
@Published private(set) var isTyping: Bool = false
private var cancellables = Set<AnyCancellable>()
init(dialogKey: String) {
self.dialogKey = dialogKey
let repo = MessageRepository.shared
// Seed with current values
messages = repo.messages(for: dialogKey)
isTyping = repo.isTyping(dialogKey: dialogKey)
// Subscribe to messagesByDialog changes, filtered to our dialog only.
// Broken into steps to help the Swift type-checker.
let key = dialogKey
let messagesPublisher = repo.$messagesByDialog
.map { (dict: [String: [ChatMessage]]) -> [ChatMessage] in
dict[key] ?? []
}
.removeDuplicates { (lhs: [ChatMessage], rhs: [ChatMessage]) -> Bool in
guard lhs.count == rhs.count else { return false }
for i in lhs.indices {
if lhs[i].id != rhs[i].id || lhs[i].deliveryStatus != rhs[i].deliveryStatus {
return false
}
}
return true
}
.receive(on: DispatchQueue.main)
messagesPublisher
.sink { [weak self] newMessages in
self?.messages = newMessages
}
.store(in: &cancellables)
// Subscribe to typing state changes, filtered to our dialog
let typingPublisher = repo.$typingDialogs
.map { (dialogs: Set<String>) -> Bool in
dialogs.contains(key)
}
.removeDuplicates()
.receive(on: DispatchQueue.main)
typingPublisher
.sink { [weak self] typing in
self?.isTyping = typing
}
.store(in: &cancellables)
}
}