From f3d5897b2b10fb4edb3aaa9b7a28ed8a35cd3380 Mon Sep 17 00:00:00 2001 From: senseiGai Date: Mon, 30 Mar 2026 21:08:31 +0500 Subject: [PATCH] =?UTF-8?q?=D0=9F=D1=83=D1=81=D1=82=D0=BE=D0=B9=20=D1=87?= =?UTF-8?q?=D0=B0=D1=82:=20glass-=D0=BF=D0=BE=D0=B4=D0=BB=D0=BE=D0=B6?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B8=20composer=20=D0=BD=D0=B0=20iOS=20<=2026,?= =?UTF-8?q?=20empty=20state=20=D0=B0=D0=BD=D0=B8=D0=BC=D0=B8=D1=80=D1=83?= =?UTF-8?q?=D0=B5=D1=82=D1=81=D1=8F=20=D1=81=20=D0=BA=D0=BB=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D0=B0=D1=82=D1=83=D1=80=D0=BE=D0=B9=20=D1=87=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=B7=20UIKit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/Repositories/DialogRepository.swift | 26 +++- .../Data/Repositories/MessageRepository.swift | 10 +- .../Core/Services/CallManager+Runtime.swift | 6 +- Rosetta/Core/Services/CallManager.swift | 2 +- Rosetta/Core/Services/SessionManager.swift | 5 + .../Chats/ChatDetail/ChatDetailView.swift | 19 ++- .../Chats/ChatDetail/NativeMessageList.swift | 111 ++++++++++++++++++ RosettaTests/ReplyPreviewTextTests.swift | 12 ++ 8 files changed, 184 insertions(+), 7 deletions(-) diff --git a/Rosetta/Core/Data/Repositories/DialogRepository.swift b/Rosetta/Core/Data/Repositories/DialogRepository.swift index e30ecb8..6d92659 100644 --- a/Rosetta/Core/Data/Repositories/DialogRepository.swift +++ b/Rosetta/Core/Data/Repositories/DialogRepository.swift @@ -169,6 +169,12 @@ final class DialogRepository { } } else if textIsEmpty { lastMessageText = "" + #if DEBUG + if !lastMsg.text.isEmpty { + print("[Dialog] ⚠️ Last message has garbled text but no attachments — opponentKey=\(opponentKey.prefix(12))… msgId=\(lastMsg.id.prefix(12))…") + print("[Dialog] text prefix: \(lastMsg.text.prefix(40))…") + } + #endif } else { lastMessageText = lastMsg.text } @@ -484,11 +490,27 @@ final class DialogRepository { // MARK: - Garbage Text Detection - /// Returns true if text is empty or contains only garbage characters - /// (replacement chars, control chars, null bytes). + /// Returns true if text is empty, looks like encrypted ciphertext, + /// or contains only garbage characters (replacement chars, control chars, null bytes). + /// Must stay aligned with MessageCellLayout.isGarbageOrEncrypted() and + /// MessageRepository.isProbablyEncryptedPayload(). private static func isGarbageText(_ text: String) -> Bool { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { return true } + + // Detect encrypted payload: ivBase64:ctBase64 format + let parts = trimmed.components(separatedBy: ":") + if parts.count == 2 { + let base64Chars = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "+/=")) + let bothBase64 = parts.allSatisfy { part in + part.count >= 16 && part.unicodeScalars.allSatisfy { base64Chars.contains($0) } + } + if bothBase64 { return true } + } + // CHNK: chunked format + if trimmed.hasPrefix("CHNK:") { return true } + + // Original check: all characters are garbage (U+FFFD, control chars, null bytes) let validCharacters = trimmed.unicodeScalars.filter { scalar in scalar.value != 0xFFFD && scalar.value > 0x1F && diff --git a/Rosetta/Core/Data/Repositories/MessageRepository.swift b/Rosetta/Core/Data/Repositories/MessageRepository.swift index 59b4ba8..e9a9ae0 100644 --- a/Rosetta/Core/Data/Repositories/MessageRepository.swift +++ b/Rosetta/Core/Data/Repositories/MessageRepository.swift @@ -863,7 +863,15 @@ final class MessageRepository: ObservableObject { plainText = decrypted } else { // Android parity: safePlainMessageFallback() — return "" if ciphertext, raw if plaintext - plainText = Self.safePlainMessageFallback(record.text) + let fallback = Self.safePlainMessageFallback(record.text) + #if DEBUG + if !fallback.isEmpty { + print("[MSG] ⚠️ decryptRecord fallback returned RAW text for msgId=\(record.messageId.prefix(12))") + print("[MSG] text prefix: \(record.text.prefix(40))…") + print("[MSG] attachments: \(record.attachments.prefix(60))…") + } + #endif + plainText = fallback } } else { plainText = Self.safePlainMessageFallback(record.text) diff --git a/Rosetta/Core/Services/CallManager+Runtime.swift b/Rosetta/Core/Services/CallManager+Runtime.swift index 1f04c3d..f1d66f8 100644 --- a/Rosetta/Core/Services/CallManager+Runtime.swift +++ b/Rosetta/Core/Services/CallManager+Runtime.swift @@ -82,7 +82,7 @@ extension CallManager { ) } - func finishCall(reason: String?, notifyPeer: Bool) { + func finishCall(reason: String?, notifyPeer: Bool, skipAttachment: Bool = false) { print("[CallBar] finishCall(reason=\(reason ?? "nil")) — phase=\(uiState.phase.rawValue) isMinimized=\(uiState.isMinimized)") // Log call stack to identify WHO triggered finishCall let symbols = Thread.callStackSymbols.prefix(8).joined(separator: "\n ") @@ -110,7 +110,9 @@ extension CallManager { ) } - if role == .caller, + // Skip call attachment for "busy" — call never connected, prevents chat flooding. + if !skipAttachment, + role == .caller, snapshot.peerPublicKey.isEmpty == false { let duration = max(snapshot.durationSec, 0) let peerKey = snapshot.peerPublicKey diff --git a/Rosetta/Core/Services/CallManager.swift b/Rosetta/Core/Services/CallManager.swift index e3f73e9..988b488 100644 --- a/Rosetta/Core/Services/CallManager.swift +++ b/Rosetta/Core/Services/CallManager.swift @@ -191,7 +191,7 @@ final class CallManager: NSObject, ObservableObject { print("[CallBar] handleSignalPacket: type=\(packet.signalType) phase=\(uiState.phase.rawValue) isMinimized=\(uiState.isMinimized)") switch packet.signalType { case .endCallBecauseBusy: - finishCall(reason: "User is busy", notifyPeer: false) + finishCall(reason: "User is busy", notifyPeer: false, skipAttachment: true) return case .endCallBecausePeerDisconnected: finishCall(reason: "Peer disconnected", notifyPeer: false) diff --git a/Rosetta/Core/Services/SessionManager.swift b/Rosetta/Core/Services/SessionManager.swift index 60d4df9..5019e95 100644 --- a/Rosetta/Core/Services/SessionManager.swift +++ b/Rosetta/Core/Services/SessionManager.swift @@ -1523,6 +1523,11 @@ final class SessionManager { fromSync: effectiveFromSync, dialogIdentityOverride: opponentKey ) + #if DEBUG + if processedPacket.attachments.contains(where: { $0.type == .call }) { + print("[CallAtt] Stored call attachment msgId=\(processedPacket.messageId.prefix(12))… text='\(text.prefix(20))' attCount=\(processedPacket.attachments.count)") + } + #endif // Android parity 1:1: dialogDao.updateDialogFromMessages(account, opponentKey) // Full recalculation of lastMessage, unread, iHaveSent, delivery from DB. DialogRepository.shared.updateDialogFromMessages(opponentKey: opponentKey) diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift index 98aaddc..9bafcc9 100644 --- a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift @@ -730,9 +730,12 @@ private extension ChatDetailView { if viewModel.isLoading && messages.isEmpty { // Android parity: skeleton placeholder while loading from DB ChatDetailSkeletonView(maxBubbleWidth: maxBubbleWidth) - } else if messages.isEmpty { + } else if #available(iOS 26, *), messages.isEmpty { + // iOS 26+: ComposerOverlay is always added in `content`, so emptyStateView alone is fine. emptyStateView } else { + // iOS < 26 empty: NativeMessageListController shows empty state + composer (UIKit). + // iOS < 26 / 26+ non-empty: normal message list. messagesScrollView(maxBubbleWidth: maxBubbleWidth) } } @@ -770,6 +773,11 @@ private extension ChatDetailView { .foregroundStyle(RosettaColors.Adaptive.textSecondary.opacity(0.7)) .padding(.top, 4) } + .padding(.horizontal, 24) + .padding(.vertical, 20) + .background { + glass(shape: .rounded(20), strokeOpacity: 0.18) + } Spacer() @@ -802,6 +810,15 @@ private extension ChatDetailView { hasMoreMessages: viewModel.hasMoreMessages, firstUnreadMessageId: firstUnreadMessageId, useUIKitComposer: useComposer, + emptyChatInfo: useComposer ? EmptyChatInfo( + title: titleText, + subtitle: subtitleText, + initials: avatarInitials, + colorIndex: avatarColorIndex, + isOnline: dialog?.isOnline ?? false, + isSavedMessages: route.isSavedMessages, + avatarImage: opponentAvatar + ) : nil, scrollToMessageId: scrollToMessageId, shouldScrollToBottom: shouldScrollOnNextMessage, scrollToBottomRequested: $scrollToBottomRequested, diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift index e69a1c4..b56f063 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift @@ -90,6 +90,10 @@ final class NativeMessageListController: UIViewController { /// Dedup for scrollViewDidScroll → onScrollToBottomVisibilityChange callback. private var lastReportedAtBottom: Bool = true + // MARK: - Empty State (UIKit-managed, animates with keyboard) + private var emptyStateHosting: UIHostingController? + private var emptyStateGuide: UILayoutGuide? + // MARK: - Layout Cache (Telegram asyncLayout pattern) /// Cache: messageId → pre-calculated layout from background thread. @@ -511,6 +515,53 @@ final class NativeMessageListController: UIViewController { } } + // MARK: - Empty State (UIKit-managed, animates with keyboard) + + func updateEmptyState(isEmpty: Bool, info: EmptyChatInfo) { + if isEmpty { + if let hosting = emptyStateHosting { + hosting.rootView = EmptyChatContent(info: info) + hosting.view.isHidden = false + } else { + setupEmptyState(info: info) + } + } else { + emptyStateHosting?.view.isHidden = true + } + } + + private func setupEmptyState(info: EmptyChatInfo) { + let hosting = UIHostingController(rootView: EmptyChatContent(info: info)) + hosting.view.backgroundColor = .clear + hosting.view.translatesAutoresizingMaskIntoConstraints = false + + addChild(hosting) + if let cv = collectionView { + view.insertSubview(hosting.view, aboveSubview: cv) + } else { + view.addSubview(hosting.view) + } + hosting.didMove(toParent: self) + + // Layout guide spans from safe area top to composer top. + // When keyboard moves the composer, this guide shrinks and + // the empty state re-centers — all in the same UIKit animation block. + let guide = UILayoutGuide() + view.addLayoutGuide(guide) + + let bottomAnchor = composerView?.topAnchor ?? view.safeAreaLayoutGuide.bottomAnchor + + NSLayoutConstraint.activate([ + guide.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + guide.bottomAnchor.constraint(equalTo: bottomAnchor), + hosting.view.centerXAnchor.constraint(equalTo: view.centerXAnchor), + hosting.view.centerYAnchor.constraint(equalTo: guide.centerYAnchor), + ]) + + emptyStateHosting = hosting + emptyStateGuide = guide + } + // MARK: - Update /// Called from SwiftUI when messages array changes. @@ -897,6 +948,8 @@ struct NativeMessageListView: UIViewControllerRepresentable { let firstUnreadMessageId: String? /// true = create UIKit ComposerView (iOS < 26). false = iOS 26+ (SwiftUI overlay). let useUIKitComposer: Bool + /// Empty chat state data (iOS < 26). nil = no empty state management. + var emptyChatInfo: EmptyChatInfo? var scrollToMessageId: String? var shouldScrollToBottom: Bool = false @Binding var scrollToBottomRequested: Bool @@ -1018,6 +1071,11 @@ struct NativeMessageListView: UIViewControllerRepresentable { controller.scrollToMessage(id: targetId, animated: true) } } + + // Empty state (iOS < 26 — UIKit-managed for keyboard animation parity) + if let info = emptyChatInfo { + controller.updateEmptyState(isEmpty: messages.isEmpty, info: info) + } } private func wireCallbacks(_ controller: NativeMessageListController, context: Context) { @@ -1079,3 +1137,56 @@ struct NativeMessageListView: UIViewControllerRepresentable { var isAtBottom: Bool = true } } + +// MARK: - Empty Chat State (UIKit-hosted SwiftUI content) + +struct EmptyChatInfo { + let title: String + let subtitle: String + let initials: String + let colorIndex: Int + let isOnline: Bool + let isSavedMessages: Bool + let avatarImage: UIImage? +} + +/// SwiftUI content rendered inside a UIHostingController so it participates +/// in the UIKit keyboard animation block (same timing as the composer). +struct EmptyChatContent: View { + let info: EmptyChatInfo + + var body: some View { + VStack(spacing: 16) { + AvatarView( + initials: info.initials, + colorIndex: info.colorIndex, + size: 80, + isOnline: info.isOnline, + isSavedMessages: info.isSavedMessages, + image: info.avatarImage + ) + + VStack(spacing: 4) { + Text(info.title) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(RosettaColors.Adaptive.text) + + if !info.isSavedMessages && !info.subtitle.isEmpty { + Text(info.subtitle) + .font(.system(size: 14, weight: .regular)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + } + } + + Text(info.isSavedMessages + ? "Save messages here for quick access" + : "No messages yet") + .font(.system(size: 15)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary.opacity(0.7)) + .padding(.top, 4) + } + .padding(.horizontal, 24) + .padding(.vertical, 20) + .background { TelegramGlassRoundedRect(cornerRadius: 20) } + } +} diff --git a/RosettaTests/ReplyPreviewTextTests.swift b/RosettaTests/ReplyPreviewTextTests.swift index af46ccf..49c0569 100644 --- a/RosettaTests/ReplyPreviewTextTests.swift +++ b/RosettaTests/ReplyPreviewTextTests.swift @@ -74,6 +74,18 @@ final class ReplyPreviewTextTests: XCTestCase { private static func isGarbageText(_ text: String) -> Bool { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { return true } + + // Detect encrypted payload: ivBase64:ctBase64 format + let parts = trimmed.components(separatedBy: ":") + if parts.count == 2 { + let base64Chars = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "+/=")) + let bothBase64 = parts.allSatisfy { part in + part.count >= 16 && part.unicodeScalars.allSatisfy { base64Chars.contains($0) } + } + if bothBase64 { return true } + } + if trimmed.hasPrefix("CHNK:") { return true } + let validCharacters = trimmed.unicodeScalars.filter { scalar in scalar.value != 0xFFFD && scalar.value > 0x1F &&