Баннер Telegram-паритета и прямой переход в чат по тапу
This commit is contained in:
@@ -325,25 +325,24 @@ struct ChatDetailView: View {
|
||||
if !draft.isEmpty {
|
||||
messageText = draft
|
||||
}
|
||||
// Suppress notifications & clear badge immediately (no 600ms delay).
|
||||
// setDialogActive only touches MessageRepository.activeDialogs (Set),
|
||||
// does NOT mutate DialogRepository, so ForEach won't rebuild.
|
||||
// Non-mutating: only touches MessageRepository (Set), safe during animation.
|
||||
MessageRepository.shared.setDialogActive(route.publicKey, isActive: true)
|
||||
SessionManager.shared.resetIdleTimer()
|
||||
updateReadEligibility()
|
||||
clearDeliveredNotifications(for: route.publicKey)
|
||||
// Telegram-like read policy: mark read only when dialog is truly readable
|
||||
// (view active + list at bottom).
|
||||
markDialogAsRead()
|
||||
DialogRepository.shared.setMention(opponentKey: route.publicKey, hasMention: false)
|
||||
// Request user info (non-mutating, won't trigger list rebuild)
|
||||
requestUserInfoIfNeeded()
|
||||
// Delay DialogRepository mutations to let navigation transition complete.
|
||||
// Without this, DialogRepository update rebuilds ChatListView's ForEach
|
||||
// mid-navigation, recreating the NavigationLink and canceling the push.
|
||||
try? await Task.sleep(for: .milliseconds(600))
|
||||
// 300ms matches UIKit push animation duration (Telegram parity).
|
||||
try? await Task.sleep(for: .milliseconds(300))
|
||||
guard isViewActive else { return }
|
||||
activateDialog()
|
||||
// Deferred from immediate .task to avoid DialogRepository mutations
|
||||
// during push animation (mutations trigger ChatListView ForEach rebuild,
|
||||
// which cancels or jitters the NavigationStack transition).
|
||||
clearDeliveredNotifications(for: route.publicKey)
|
||||
markDialogAsRead()
|
||||
DialogRepository.shared.setMention(opponentKey: route.publicKey, hasMention: false)
|
||||
requestUserInfoIfNeeded()
|
||||
updateReadEligibility()
|
||||
markDialogAsRead()
|
||||
// Desktop parity: skip online subscription and user info fetch for system accounts
|
||||
|
||||
@@ -1934,7 +1934,10 @@ struct NativeMessageListView: UIViewControllerRepresentable {
|
||||
|
||||
// Force view load so dataSource/collectionView are initialized.
|
||||
controller.loadViewIfNeeded()
|
||||
controller.update(messages: messages)
|
||||
// Do NOT call update(messages:) here — defer to updateUIViewController.
|
||||
// Telegram-iOS pattern: controller starts empty/skeleton, messages load
|
||||
// in the first updateUIViewController pass. This keeps makeUIViewController
|
||||
// lightweight so NavigationStack push animation starts instantly.
|
||||
|
||||
// Apply initial composer state
|
||||
if useUIKitComposer {
|
||||
|
||||
@@ -96,6 +96,9 @@ struct ChatListView: View {
|
||||
isDetailPresented = presented
|
||||
}
|
||||
)
|
||||
// Force a fresh ChatDetailView when route changes at the same stack depth.
|
||||
// This avoids stale message content when switching chats via notification/banner.
|
||||
.id(route.publicKey)
|
||||
}
|
||||
.navigationDestination(isPresented: $showRequestChats) {
|
||||
RequestChatsView(
|
||||
@@ -145,17 +148,30 @@ struct ChatListView: View {
|
||||
guard let route = notification.object as? ChatRoute else { return }
|
||||
AppDelegate.pendingChatRoute = nil
|
||||
AppDelegate.pendingChatRouteTimestamp = nil
|
||||
// If already inside a chat, pop first then push after animation.
|
||||
// Direct path replacement reuses the same ChatDetailView (SwiftUI optimization),
|
||||
// which only updates the toolbar but keeps the old messages.
|
||||
|
||||
// Already showing this chat.
|
||||
if !showRequestChats, navigationState.path.last?.publicKey == route.publicKey {
|
||||
return
|
||||
}
|
||||
|
||||
// If user is in a chat already, push target chat immediately on top.
|
||||
// This avoids the list flash while still creating a fresh destination.
|
||||
if !navigationState.path.isEmpty {
|
||||
navigationState.path = []
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
|
||||
navigationState.path.append(route)
|
||||
return
|
||||
}
|
||||
|
||||
// If Requests screen is open, close it first, then open chat.
|
||||
if showRequestChats {
|
||||
showRequestChats = false
|
||||
DispatchQueue.main.async {
|
||||
navigationState.path = [route]
|
||||
}
|
||||
} else {
|
||||
navigationState.path = [route]
|
||||
return
|
||||
}
|
||||
|
||||
// Root chat-list state: open target chat directly.
|
||||
navigationState.path = [route]
|
||||
}
|
||||
.onAppear {
|
||||
// Cold start fallback: ChatListView didn't exist when notification was posted.
|
||||
|
||||
@@ -136,9 +136,12 @@ final class RequestChatsController: UIViewController {
|
||||
collectionView.translatesAutoresizingMaskIntoConstraints = false
|
||||
collectionView.backgroundColor = .clear
|
||||
collectionView.delegate = self
|
||||
collectionView.showsHorizontalScrollIndicator = false
|
||||
collectionView.showsVerticalScrollIndicator = false
|
||||
collectionView.alwaysBounceHorizontal = false
|
||||
collectionView.alwaysBounceVertical = true
|
||||
collectionView.contentInset.bottom = 80
|
||||
collectionView.contentInset.bottom = 0
|
||||
collectionView.verticalScrollIndicatorInsets.bottom = 0
|
||||
view.addSubview(collectionView)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
|
||||
@@ -73,6 +73,7 @@ final class ChatListCell: UICollectionViewCell {
|
||||
private var isPinned = false
|
||||
private var wasBadgeVisible = false
|
||||
private var wasMentionBadgeVisible = false
|
||||
private var isSystemChat = false
|
||||
|
||||
// MARK: - Init
|
||||
|
||||
@@ -354,8 +355,10 @@ final class ChatListCell: UICollectionViewCell {
|
||||
)
|
||||
} else {
|
||||
authorLabel.frame = .zero
|
||||
// System chats (Updates/Safe): keep preview visually farther from title.
|
||||
let messageY: CGFloat = isSystemChat ? 29 : 21
|
||||
messageLabel.frame = CGRect(
|
||||
x: textLeft, y: 21,
|
||||
x: textLeft, y: messageY,
|
||||
width: max(0, messageMaxW), height: 38
|
||||
)
|
||||
}
|
||||
@@ -389,6 +392,7 @@ final class ChatListCell: UICollectionViewCell {
|
||||
func configure(with dialog: Dialog, isSyncing: Bool, typingUsers: Set<String>? = nil) {
|
||||
let isDark = traitCollection.userInterfaceStyle == .dark
|
||||
isPinned = dialog.isPinned
|
||||
isSystemChat = isSystemDialog(dialog)
|
||||
|
||||
// Colors
|
||||
let titleColor = isDark ? UIColor.white : UIColor.black
|
||||
@@ -696,6 +700,15 @@ final class ChatListCell: UICollectionViewCell {
|
||||
messageLabel.textColor = secondaryColor
|
||||
}
|
||||
|
||||
private func isSystemDialog(_ dialog: Dialog) -> Bool {
|
||||
if SystemAccounts.isSystemAccount(dialog.opponentKey) { return true }
|
||||
if dialog.opponentTitle.caseInsensitiveCompare(SystemAccounts.updatesTitle) == .orderedSame { return true }
|
||||
if dialog.opponentTitle.caseInsensitiveCompare(SystemAccounts.safeTitle) == .orderedSame { return true }
|
||||
if dialog.opponentUsername.caseInsensitiveCompare("updates") == .orderedSame { return true }
|
||||
if dialog.opponentUsername.caseInsensitiveCompare("safe") == .orderedSame { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
// MARK: - Typing Indicator
|
||||
|
||||
private func configureTypingIndicator(dialog: Dialog, typingUsers: Set<String>, color: UIColor) {
|
||||
@@ -859,6 +872,7 @@ final class ChatListCell: UICollectionViewCell {
|
||||
wasMentionBadgeVisible = false
|
||||
badgeContainer.transform = .identity
|
||||
mentionImageView.transform = .identity
|
||||
isSystemChat = false
|
||||
}
|
||||
|
||||
// MARK: - Highlight
|
||||
|
||||
@@ -45,6 +45,16 @@ final class ChatListCollectionController: UIViewController {
|
||||
private var dataSource: UICollectionViewDiffableDataSource<Section, String>!
|
||||
private var cellRegistration: UICollectionView.CellRegistration<ChatListCell, Dialog>!
|
||||
private var requestsCellRegistration: UICollectionView.CellRegistration<ChatListRequestsCell, Int>!
|
||||
private let floatingTabBarTotalHeight: CGFloat = 72
|
||||
private var chatListBottomInset: CGFloat {
|
||||
if #available(iOS 26, *) {
|
||||
return 0
|
||||
} else {
|
||||
// contentInsetAdjustmentBehavior(.automatic) already contributes safe-area bottom.
|
||||
// Add only the remaining space covered by the custom floating tab bar.
|
||||
return max(0, floatingTabBarTotalHeight - view.safeAreaInsets.bottom)
|
||||
}
|
||||
}
|
||||
|
||||
// Dialog lookup by ID for cell configuration
|
||||
private var dialogMap: [String: Dialog] = [:]
|
||||
@@ -71,10 +81,11 @@ final class ChatListCollectionController: UIViewController {
|
||||
collectionView.prefetchDataSource = self
|
||||
collectionView.keyboardDismissMode = .onDrag
|
||||
collectionView.showsVerticalScrollIndicator = false
|
||||
collectionView.showsHorizontalScrollIndicator = false
|
||||
collectionView.alwaysBounceVertical = true
|
||||
collectionView.alwaysBounceHorizontal = false
|
||||
collectionView.contentInsetAdjustmentBehavior = .automatic
|
||||
// Bottom inset so last cells aren't hidden behind tab bar
|
||||
collectionView.contentInset.bottom = 80
|
||||
applyBottomInsets()
|
||||
view.addSubview(collectionView)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
@@ -85,6 +96,23 @@ final class ChatListCollectionController: UIViewController {
|
||||
])
|
||||
}
|
||||
|
||||
override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
applyBottomInsets()
|
||||
}
|
||||
|
||||
override func viewSafeAreaInsetsDidChange() {
|
||||
super.viewSafeAreaInsetsDidChange()
|
||||
applyBottomInsets()
|
||||
}
|
||||
|
||||
private func applyBottomInsets() {
|
||||
guard collectionView != nil else { return }
|
||||
let inset = chatListBottomInset
|
||||
collectionView.contentInset.bottom = inset
|
||||
collectionView.verticalScrollIndicatorInsets.bottom = inset
|
||||
}
|
||||
|
||||
private func createLayout() -> UICollectionViewCompositionalLayout {
|
||||
var listConfig = UICollectionLayoutListConfiguration(appearance: .plain)
|
||||
listConfig.showsSeparators = false
|
||||
|
||||
Reference in New Issue
Block a user