Пустой чат: glass-подложка и composer на iOS < 26, empty state анимируется с клавиатурой через UIKit

This commit is contained in:
2026-03-30 21:08:31 +05:00
parent dcefce7cd5
commit f3d5897b2b
8 changed files with 184 additions and 7 deletions

View File

@@ -169,6 +169,12 @@ final class DialogRepository {
} }
} else if textIsEmpty { } else if textIsEmpty {
lastMessageText = "" 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 { } else {
lastMessageText = lastMsg.text lastMessageText = lastMsg.text
} }
@@ -484,11 +490,27 @@ final class DialogRepository {
// MARK: - Garbage Text Detection // MARK: - Garbage Text Detection
/// Returns true if text is empty or contains only garbage characters /// Returns true if text is empty, looks like encrypted ciphertext,
/// (replacement chars, control chars, null bytes). /// 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 { private static func isGarbageText(_ text: String) -> Bool {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return true } 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 let validCharacters = trimmed.unicodeScalars.filter { scalar in
scalar.value != 0xFFFD && scalar.value != 0xFFFD &&
scalar.value > 0x1F && scalar.value > 0x1F &&

View File

@@ -863,7 +863,15 @@ final class MessageRepository: ObservableObject {
plainText = decrypted plainText = decrypted
} else { } else {
// Android parity: safePlainMessageFallback() return "" if ciphertext, raw if plaintext // 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 { } else {
plainText = Self.safePlainMessageFallback(record.text) plainText = Self.safePlainMessageFallback(record.text)

View File

@@ -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)") print("[CallBar] finishCall(reason=\(reason ?? "nil")) — phase=\(uiState.phase.rawValue) isMinimized=\(uiState.isMinimized)")
// Log call stack to identify WHO triggered finishCall // Log call stack to identify WHO triggered finishCall
let symbols = Thread.callStackSymbols.prefix(8).joined(separator: "\n ") 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 { snapshot.peerPublicKey.isEmpty == false {
let duration = max(snapshot.durationSec, 0) let duration = max(snapshot.durationSec, 0)
let peerKey = snapshot.peerPublicKey let peerKey = snapshot.peerPublicKey

View File

@@ -191,7 +191,7 @@ final class CallManager: NSObject, ObservableObject {
print("[CallBar] handleSignalPacket: type=\(packet.signalType) phase=\(uiState.phase.rawValue) isMinimized=\(uiState.isMinimized)") print("[CallBar] handleSignalPacket: type=\(packet.signalType) phase=\(uiState.phase.rawValue) isMinimized=\(uiState.isMinimized)")
switch packet.signalType { switch packet.signalType {
case .endCallBecauseBusy: case .endCallBecauseBusy:
finishCall(reason: "User is busy", notifyPeer: false) finishCall(reason: "User is busy", notifyPeer: false, skipAttachment: true)
return return
case .endCallBecausePeerDisconnected: case .endCallBecausePeerDisconnected:
finishCall(reason: "Peer disconnected", notifyPeer: false) finishCall(reason: "Peer disconnected", notifyPeer: false)

View File

@@ -1523,6 +1523,11 @@ final class SessionManager {
fromSync: effectiveFromSync, fromSync: effectiveFromSync,
dialogIdentityOverride: opponentKey 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) // Android parity 1:1: dialogDao.updateDialogFromMessages(account, opponentKey)
// Full recalculation of lastMessage, unread, iHaveSent, delivery from DB. // Full recalculation of lastMessage, unread, iHaveSent, delivery from DB.
DialogRepository.shared.updateDialogFromMessages(opponentKey: opponentKey) DialogRepository.shared.updateDialogFromMessages(opponentKey: opponentKey)

View File

@@ -730,9 +730,12 @@ private extension ChatDetailView {
if viewModel.isLoading && messages.isEmpty { if viewModel.isLoading && messages.isEmpty {
// Android parity: skeleton placeholder while loading from DB // Android parity: skeleton placeholder while loading from DB
ChatDetailSkeletonView(maxBubbleWidth: maxBubbleWidth) 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 emptyStateView
} else { } else {
// iOS < 26 empty: NativeMessageListController shows empty state + composer (UIKit).
// iOS < 26 / 26+ non-empty: normal message list.
messagesScrollView(maxBubbleWidth: maxBubbleWidth) messagesScrollView(maxBubbleWidth: maxBubbleWidth)
} }
} }
@@ -770,6 +773,11 @@ private extension ChatDetailView {
.foregroundStyle(RosettaColors.Adaptive.textSecondary.opacity(0.7)) .foregroundStyle(RosettaColors.Adaptive.textSecondary.opacity(0.7))
.padding(.top, 4) .padding(.top, 4)
} }
.padding(.horizontal, 24)
.padding(.vertical, 20)
.background {
glass(shape: .rounded(20), strokeOpacity: 0.18)
}
Spacer() Spacer()
@@ -802,6 +810,15 @@ private extension ChatDetailView {
hasMoreMessages: viewModel.hasMoreMessages, hasMoreMessages: viewModel.hasMoreMessages,
firstUnreadMessageId: firstUnreadMessageId, firstUnreadMessageId: firstUnreadMessageId,
useUIKitComposer: useComposer, 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, scrollToMessageId: scrollToMessageId,
shouldScrollToBottom: shouldScrollOnNextMessage, shouldScrollToBottom: shouldScrollOnNextMessage,
scrollToBottomRequested: $scrollToBottomRequested, scrollToBottomRequested: $scrollToBottomRequested,

View File

@@ -90,6 +90,10 @@ final class NativeMessageListController: UIViewController {
/// Dedup for scrollViewDidScroll onScrollToBottomVisibilityChange callback. /// Dedup for scrollViewDidScroll onScrollToBottomVisibilityChange callback.
private var lastReportedAtBottom: Bool = true private var lastReportedAtBottom: Bool = true
// MARK: - Empty State (UIKit-managed, animates with keyboard)
private var emptyStateHosting: UIHostingController<EmptyChatContent>?
private var emptyStateGuide: UILayoutGuide?
// MARK: - Layout Cache (Telegram asyncLayout pattern) // MARK: - Layout Cache (Telegram asyncLayout pattern)
/// Cache: messageId pre-calculated layout from background thread. /// 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 // MARK: - Update
/// Called from SwiftUI when messages array changes. /// Called from SwiftUI when messages array changes.
@@ -897,6 +948,8 @@ struct NativeMessageListView: UIViewControllerRepresentable {
let firstUnreadMessageId: String? let firstUnreadMessageId: String?
/// true = create UIKit ComposerView (iOS < 26). false = iOS 26+ (SwiftUI overlay). /// true = create UIKit ComposerView (iOS < 26). false = iOS 26+ (SwiftUI overlay).
let useUIKitComposer: Bool let useUIKitComposer: Bool
/// Empty chat state data (iOS < 26). nil = no empty state management.
var emptyChatInfo: EmptyChatInfo?
var scrollToMessageId: String? var scrollToMessageId: String?
var shouldScrollToBottom: Bool = false var shouldScrollToBottom: Bool = false
@Binding var scrollToBottomRequested: Bool @Binding var scrollToBottomRequested: Bool
@@ -1018,6 +1071,11 @@ struct NativeMessageListView: UIViewControllerRepresentable {
controller.scrollToMessage(id: targetId, animated: true) 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) { private func wireCallbacks(_ controller: NativeMessageListController, context: Context) {
@@ -1079,3 +1137,56 @@ struct NativeMessageListView: UIViewControllerRepresentable {
var isAtBottom: Bool = true 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) }
}
}

View File

@@ -74,6 +74,18 @@ final class ReplyPreviewTextTests: XCTestCase {
private static func isGarbageText(_ text: String) -> Bool { private static func isGarbageText(_ text: String) -> Bool {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return true } 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 let validCharacters = trimmed.unicodeScalars.filter { scalar in
scalar.value != 0xFFFD && scalar.value != 0xFFFD &&
scalar.value > 0x1F && scalar.value > 0x1F &&