Пустой чат: glass-подложка и composer на iOS < 26, empty state анимируется с клавиатурой через UIKit
This commit is contained in:
@@ -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 &&
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<EmptyChatContent>?
|
||||
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) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
Reference in New Issue
Block a user