Исправление расширения поля пароля при переключении видимости: перенос toggle в UIKit
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
65
Rosetta/Features/Chats/ChatDetail/ChatDetailViewModel.swift
Normal file
65
Rosetta/Features/Chats/ChatDetail/ChatDetailViewModel.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user