Фикс: починил индикацию прочитанных сообщений после миграции на UIKit

This commit is contained in:
2026-04-15 17:36:53 +05:00
parent c3260889f4
commit c43e83ab89
4 changed files with 198 additions and 34 deletions

View File

@@ -59,6 +59,12 @@ final class ChatDetailViewController: UIViewController {
// MARK: - Edge Effects
private let topEdgeEffectView = VariableBlurEdgeView(frame: .zero)
private let bottomEdgeGradientView: UIView = {
let v = UIView()
v.isUserInteractionEnabled = false
return v
}()
private let bottomGradientLayer = CAGradientLayer()
// MARK: - Init
@@ -83,6 +89,13 @@ final class ChatDetailViewController: UIViewController {
setupEdgeEffects()
wireCellActions()
wireViewModelSubscriptions()
NotificationCenter.default.addObserver(
self,
selector: #selector(appDidBecomeActive),
name: UIApplication.didBecomeActiveNotification,
object: nil
)
}
override func viewWillAppear(_ animated: Bool) {
@@ -185,6 +198,17 @@ final class ChatDetailViewController: UIViewController {
controller.loadViewIfNeeded()
messageListController = controller
// Fix date pill sticky offset for floating header
messageListController.topStickyOffset = headerBarHeight
// Reparent pill overlay: above edge effect (z=40), below toolbar (z=55)
let overlay = messageListController.datePillOverlay
overlay.removeFromSuperview()
overlay.layer.zPosition = 45
overlay.frame = view.bounds
overlay.autoresizingMask = [.flexibleWidth, .flexibleHeight]
view.addSubview(overlay)
}
// MARK: - Toolbar Setup (glass capsules as direct subviews)
@@ -266,12 +290,45 @@ final class ChatDetailViewController: UIViewController {
let isDark = traitCollection.userInterfaceStyle == .dark
topEdgeEffectView.setTintColor(isDark ? .black : .white)
view.addSubview(topEdgeEffectView)
// Bottom gradient: simple tint fade (no blur) above composer area
bottomEdgeGradientView.layer.zPosition = 40
bottomGradientLayer.startPoint = CGPoint(x: 0.5, y: 0)
bottomGradientLayer.endPoint = CGPoint(x: 0.5, y: 1)
updateBottomGradientColors()
bottomEdgeGradientView.layer.addSublayer(bottomGradientLayer)
view.addSubview(bottomEdgeGradientView)
}
private func updateEdgeEffectFrames() {
let edgeHeight = view.safeAreaInsets.top + headerBarHeight + 14
topEdgeEffectView.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: edgeHeight)
topEdgeEffectView.update(size: topEdgeEffectView.bounds.size, edgeSize: 54, contentAlpha: 0.85)
// Bottom gradient: tint fade below composer, in bottom safe area
let bottomSafe = view.safeAreaInsets.bottom
if bottomSafe > 0 {
bottomEdgeGradientView.isHidden = false
bottomEdgeGradientView.frame = CGRect(
x: 0,
y: view.bounds.height - bottomSafe,
width: view.bounds.width,
height: bottomSafe
)
bottomGradientLayer.frame = bottomEdgeGradientView.bounds
} else {
bottomEdgeGradientView.isHidden = true
}
}
private func updateBottomGradientColors() {
let bg = traitCollection.userInterfaceStyle == .dark
? UIColor.black
: UIColor.white
bottomGradientLayer.colors = [
bg.withAlphaComponent(0).cgColor,
bg.withAlphaComponent(1).cgColor,
]
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
@@ -279,6 +336,7 @@ final class ChatDetailViewController: UIViewController {
if traitCollection.userInterfaceStyle != previousTraitCollection?.userInterfaceStyle {
let isDark = traitCollection.userInterfaceStyle == .dark
topEdgeEffectView.setTintColor(isDark ? .black : .white)
updateBottomGradientColors()
}
}
@@ -286,7 +344,13 @@ final class ChatDetailViewController: UIViewController {
private func wireMessageListCallbacks(_ controller: NativeMessageListController) {
controller.onScrollToBottomVisibilityChange = { [weak self] atBottom in
self?.isAtBottom = atBottom
guard let self else { return }
self.isAtBottom = atBottom
SessionManager.shared.resetIdleTimer()
self.updateReadEligibility()
if atBottom {
self.markDialogAsRead()
}
}
controller.onPaginationTrigger = { [weak self] in
Task { await self?.viewModel.loadMore() }
@@ -360,6 +424,12 @@ final class ChatDetailViewController: UIViewController {
DispatchQueue.main.async {
controller.scrollToBottom(animated: true)
}
// Mark incoming auto-scrolled messages as read (SwiftUI onNewMessageAutoScroll parity)
if isViewActive && !lastIsOutgoing
&& !route.isSavedMessages && !route.isSystemAccount {
updateReadEligibility()
markDialogAsRead()
}
}
}
@@ -798,6 +868,16 @@ final class ChatDetailViewController: UIViewController {
MessageRepository.shared.setDialogReadEligible(route.publicKey, isEligible: isViewActive && isAtBottom)
}
@objc private func appDidBecomeActive() {
guard isViewActive else { return }
SessionManager.shared.resetIdleTimer()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak self] in
guard let self, self.isViewActive else { return }
self.updateReadEligibility()
self.markDialogAsRead()
}
}
private func activateDialog() {
if DialogRepository.shared.dialogs[route.publicKey] != nil {
DialogRepository.shared.ensureDialog(

View File

@@ -143,6 +143,16 @@ final class NativeMessageListController: UIViewController {
private var dateHideTimer: Timer?
private var areDatePillsVisible = false
private var animatePillFrames = false
/// Extra top offset for sticky pills when parent has floating header overlay.
/// Default 0 preserves behavior for SwiftUI host (where nav bar inflates safe area).
var topStickyOffset: CGFloat = 0
/// Container for floating date pills parent VC can reparent for z-ordering.
let datePillOverlay: UIView = {
let v = UIView()
v.isUserInteractionEnabled = false
v.backgroundColor = .clear
return v
}()
// MARK: - Empty State (UIKit-managed, animates with keyboard)
private var emptyStateHosting: UIHostingController<EmptyChatContent>?
@@ -826,10 +836,14 @@ final class NativeMessageListController: UIViewController {
}
private func setupFloatingDateHeader() {
datePillOverlay.frame = view.bounds
datePillOverlay.autoresizingMask = [.flexibleWidth, .flexibleHeight]
view.addSubview(datePillOverlay)
// Pre-create pool of 4 pills (max visible date sections at once).
for _ in 0..<4 {
let pill = makeDatePill()
view.addSubview(pill.container)
datePillOverlay.addSubview(pill.container)
datePillPool.append(pill)
}
}
@@ -846,7 +860,7 @@ final class NativeMessageListController: UIViewController {
let pillH: CGFloat = 24
let hPad: CGFloat = 7
let stickyY = view.safeAreaInsets.top + 8
let stickyY = view.safeAreaInsets.top + topStickyOffset + 8
// 1. Group visible cells by date section ranges in screen coords.
struct DateSection {
@@ -887,11 +901,7 @@ final class NativeMessageListController: UIViewController {
// 2. Expand pool if more sections than pills (short chats spanning many days).
while datePillPool.count < sections.count {
let pill = makeDatePill()
if let composer = composerView {
view.insertSubview(pill.container, belowSubview: composer)
} else {
view.addSubview(pill.container)
}
datePillOverlay.addSubview(pill.container)
datePillPool.append(pill)
}

View File

@@ -194,11 +194,10 @@ private extension ChatListSearchContent {
)
}
}
// Telegram parity: subtitle shows online status, not @username
if !isSelf {
let dialog = DialogRepository.shared.dialogs[user.publicKey]
let isOnline = dialog?.isOnline ?? false
Text(isOnline ? "online" : "last seen recently")
Text(isOnline ? "online" : "offline")
.font(.system(size: 13))
.foregroundStyle(isOnline
? RosettaColors.primaryBlue

View File

@@ -222,8 +222,9 @@ final class ChatListRootViewController: UIViewController, UINavigationController
editButtonControl.isUserInteractionEnabled = true
rightButtonsControl.isUserInteractionEnabled = true
toolbarTitleView.isUserInteractionEnabled = true
// Restore search bar position and tear down overlay
// Restore search bar position, blur, and tear down overlay
searchHeaderTopConstraint?.constant = headerBarHeight + searchTopSpacing
navigationBlurView.isHidden = false
teardownSearchOverlayImmediately()
}
}
@@ -308,7 +309,9 @@ final class ChatListRootViewController: UIViewController, UINavigationController
searchHeaderView.onActiveChanged = { [weak self] active in
guard let self else { return }
self.applySearchExpansion(1.0, animated: true)
self.updateNavigationBarBlur(progress: active ? 1.0 : (1.0 - self.lastSearchExpansion))
// Hide blur when search active (search bar moves up, no blur needed)
self.updateNavigationBarBlur(progress: active ? 0.0 : (1.0 - self.lastSearchExpansion))
self.navigationBlurView.isHidden = active
self.animateToolbarForSearch(active: active)
self.animateSearchBarPosition(active: active)
if active {
@@ -515,7 +518,7 @@ final class ChatListRootViewController: UIViewController, UINavigationController
view.insertSubview(bg, belowSubview: searchHeaderView)
NSLayoutConstraint.activate([
bg.topAnchor.constraint(equalTo: searchHeaderView.bottomAnchor, constant: searchBottomSpacing),
bg.topAnchor.constraint(equalTo: view.topAnchor),
bg.leadingAnchor.constraint(equalTo: view.leadingAnchor),
bg.trailingAnchor.constraint(equalTo: view.trailingAnchor),
bg.bottomAnchor.constraint(equalTo: view.bottomAnchor),
@@ -531,12 +534,25 @@ final class ChatListRootViewController: UIViewController, UINavigationController
hosting.didMove(toParent: self)
NSLayoutConstraint.activate([
hosting.view.topAnchor.constraint(equalTo: bg.topAnchor),
// Content starts below search bar (not behind it)
hosting.view.topAnchor.constraint(equalTo: searchHeaderView.bottomAnchor, constant: searchBottomSpacing),
hosting.view.leadingAnchor.constraint(equalTo: bg.leadingAnchor),
hosting.view.trailingAnchor.constraint(equalTo: bg.trailingAnchor),
hosting.view.bottomAnchor.constraint(equalTo: bg.bottomAnchor),
])
// Edge fade gradient below search bar (Telegram-style)
let edgeFade = CAGradientLayer()
edgeFade.colors = [
UIColor(RosettaColors.Adaptive.background).cgColor,
UIColor(RosettaColors.Adaptive.background).withAlphaComponent(0).cgColor,
]
edgeFade.locations = [0, 1]
edgeFade.startPoint = CGPoint(x: 0.5, y: 0)
edgeFade.endPoint = CGPoint(x: 0.5, y: 1)
edgeFade.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 14)
hosting.view.layer.addSublayer(edgeFade)
searchOverlayView = bg
searchContentHosting = hosting
@@ -1113,6 +1129,7 @@ private final class ChatListSearchHeaderView: UIView, UITextFieldDelegate {
private(set) var isSearchActive = false
private let capsuleView = UIView()
private let capsuleGlass = TelegramGlassUIView(frame: .zero)
private let placeholderStack = UIStackView()
private let placeholderIcon = UIImageView(image: UIImage(systemName: "magnifyingglass"))
private let placeholderLabel = UILabel()
@@ -1121,9 +1138,11 @@ private final class ChatListSearchHeaderView: UIView, UITextFieldDelegate {
private let activeIcon = UIImageView(image: UIImage(systemName: "magnifyingglass"))
private let textField = UITextField()
private let inlineClearButton = UIButton(type: .system)
private let cancelButton = UIButton(type: .system)
private let cancelButton = UIButton(type: .custom)
private let cancelGlass = TelegramGlassUIView(frame: .zero)
private var cancelWidthConstraint: NSLayoutConstraint!
private var capsuleTrailingConstraint: NSLayoutConstraint!
private var suppressQueryCallback = false
override init(frame: CGRect) {
@@ -1142,6 +1161,17 @@ private final class ChatListSearchHeaderView: UIView, UITextFieldDelegate {
applyColors()
}
override func layoutSubviews() {
super.layoutSubviews()
// Update glass frames and corner radii
capsuleGlass.frame = capsuleView.bounds
capsuleGlass.fixedCornerRadius = capsuleView.bounds.height * 0.5
capsuleGlass.updateGlass()
// cancelGlass uses constraints (sibling of cancelButton), just update glass
cancelGlass.fixedCornerRadius = cancelButton.bounds.height * 0.5
cancelGlass.updateGlass()
}
func endSearch(animated: Bool, clearText: Bool) {
setSearchActive(false, animated: animated, clearText: clearText)
}
@@ -1158,7 +1188,9 @@ private final class ChatListSearchHeaderView: UIView, UITextFieldDelegate {
func updateExpansionProgress(_ progress: CGFloat) {
let currentHeight = searchBarHeight * progress
// Dynamic corner radius Telegram: height * 0.5
capsuleView.layer.cornerRadius = max(0, currentHeight * 0.5)
let radius = max(0, currentHeight * 0.5)
capsuleGlass.fixedCornerRadius = radius
capsuleGlass.updateGlass()
// Telegram inner content alpha: 0 until 77%, then ramps to 1
let innerAlpha = max(0.0, min(1.0, (progress - 0.77) / 0.23))
@@ -1174,8 +1206,12 @@ private final class ChatListSearchHeaderView: UIView, UITextFieldDelegate {
capsuleView.translatesAutoresizingMaskIntoConstraints = false
capsuleView.layer.cornerRadius = 22
capsuleView.layer.borderWidth = 0
capsuleView.clipsToBounds = true
// Glass background for capsule (Telegram active search material)
capsuleGlass.fixedCornerRadius = 22
capsuleGlass.isHidden = true
capsuleGlass.autoresizingMask = [.flexibleWidth, .flexibleHeight]
capsuleView.insertSubview(capsuleGlass, at: 0)
addSubview(capsuleView)
placeholderStack.translatesAutoresizingMaskIntoConstraints = false
@@ -1216,13 +1252,20 @@ private final class ChatListSearchHeaderView: UIView, UITextFieldDelegate {
activeStack.addArrangedSubview(inlineClearButton)
capsuleView.addSubview(activeStack)
// Telegram-style circular X button (replaces "Cancel" text)
// Glass background for X button (sibling, positioned behind)
cancelGlass.translatesAutoresizingMaskIntoConstraints = false
cancelGlass.fixedCornerRadius = 22
cancelGlass.isUserInteractionEnabled = false
cancelGlass.isHidden = true
addSubview(cancelGlass)
// Telegram-style circular X button: 44pt, glass material
cancelButton.translatesAutoresizingMaskIntoConstraints = false
let xConfig = UIImage.SymbolConfiguration(pointSize: 14, weight: .semibold)
let xConfig = UIImage.SymbolConfiguration(pointSize: 16, weight: .medium)
cancelButton.setImage(UIImage(systemName: "xmark", withConfiguration: xConfig), for: .normal)
cancelButton.setTitle(nil, for: .normal)
cancelButton.layer.cornerRadius = 18
cancelButton.clipsToBounds = true
cancelButton.clipsToBounds = false
cancelButton.backgroundColor = .clear
cancelButton.addTarget(self, action: #selector(handleCancelTapped), for: .touchUpInside)
addSubview(cancelButton)
@@ -1230,38 +1273,45 @@ private final class ChatListSearchHeaderView: UIView, UITextFieldDelegate {
capsuleView.addGestureRecognizer(tap)
cancelWidthConstraint = cancelButton.widthAnchor.constraint(equalToConstant: 0)
// Fix 1: animate gap 0 when inactive (symmetric), -8 when active
let trailing = capsuleView.trailingAnchor.constraint(equalTo: cancelButton.leadingAnchor, constant: 0)
capsuleTrailingConstraint = trailing
NSLayoutConstraint.activate([
capsuleView.leadingAnchor.constraint(equalTo: leadingAnchor),
capsuleView.topAnchor.constraint(equalTo: topAnchor),
capsuleView.bottomAnchor.constraint(equalTo: bottomAnchor),
capsuleView.trailingAnchor.constraint(equalTo: cancelButton.leadingAnchor, constant: -8),
trailing,
cancelButton.trailingAnchor.constraint(equalTo: trailingAnchor),
cancelButton.centerYAnchor.constraint(equalTo: centerYAnchor),
cancelButton.heightAnchor.constraint(equalToConstant: 36),
cancelButton.heightAnchor.constraint(equalToConstant: 44),
cancelWidthConstraint,
// Glass behind X button (same frame)
cancelGlass.topAnchor.constraint(equalTo: cancelButton.topAnchor),
cancelGlass.leadingAnchor.constraint(equalTo: cancelButton.leadingAnchor),
cancelGlass.trailingAnchor.constraint(equalTo: cancelButton.trailingAnchor),
cancelGlass.bottomAnchor.constraint(equalTo: cancelButton.bottomAnchor),
placeholderStack.centerXAnchor.constraint(equalTo: capsuleView.centerXAnchor),
placeholderStack.centerYAnchor.constraint(equalTo: capsuleView.centerYAnchor),
activeStack.leadingAnchor.constraint(equalTo: capsuleView.leadingAnchor, constant: 12),
// Fix 4: leading 8pt (Telegram parity)
activeStack.leadingAnchor.constraint(equalTo: capsuleView.leadingAnchor, constant: 8),
activeStack.trailingAnchor.constraint(equalTo: capsuleView.trailingAnchor, constant: -10),
activeStack.topAnchor.constraint(equalTo: capsuleView.topAnchor),
activeStack.bottomAnchor.constraint(equalTo: capsuleView.bottomAnchor),
// Fix 2: fixed width prevents stretching to 44pt height
activeIcon.widthAnchor.constraint(equalToConstant: 20),
inlineClearButton.widthAnchor.constraint(equalToConstant: 24),
inlineClearButton.heightAnchor.constraint(equalToConstant: 24),
])
}
private func applyColors() {
// Telegram exact colors from DefaultDarkPresentationTheme / DefaultDayPresentationTheme
let isDark = traitCollection.userInterfaceStyle == .dark
// regularSearchBarColor: dark #272728, light #e9e9e9
capsuleView.backgroundColor = isDark
? UIColor(red: 0x27/255.0, green: 0x27/255.0, blue: 0x28/255.0, alpha: 1.0)
: UIColor(red: 0xe9/255.0, green: 0xe9/255.0, blue: 0xe9/255.0, alpha: 1.0)
// inputPlaceholderTextColor: dark #8f8f8f, light #8e8e93
let placeholderColor = isDark
? UIColor(red: 0x8f/255.0, green: 0x8f/255.0, blue: 0x8f/255.0, alpha: 1.0)
@@ -1272,19 +1322,44 @@ private final class ChatListSearchHeaderView: UIView, UITextFieldDelegate {
// inputTextColor: dark #ffffff, light #000000
textField.textColor = isDark ? .white : .black
inlineClearButton.tintColor = placeholderColor
// Telegram X button: dark circle background, white icon
cancelButton.backgroundColor = isDark
? UIColor(red: 0x2c/255.0, green: 0x2c/255.0, blue: 0x2e/255.0, alpha: 1.0)
: UIColor(red: 0xd1/255.0, green: 0xd1/255.0, blue: 0xd6/255.0, alpha: 1.0)
// X button icon: white on dark, black on light
cancelButton.tintColor = isDark ? .white : .black
// Apply correct background for current state
applyCapsuleBackground()
}
/// Inactive: solid #272728. Active: TelegramGlassUIView (glass material).
private func applyCapsuleBackground() {
let isDark = traitCollection.userInterfaceStyle == .dark
let solidFill = isDark
? UIColor(red: 0x27/255.0, green: 0x27/255.0, blue: 0x28/255.0, alpha: 1.0)
: UIColor(red: 0xe9/255.0, green: 0xe9/255.0, blue: 0xe9/255.0, alpha: 1.0)
if isSearchActive {
// Glass material (Telegram active search)
capsuleView.backgroundColor = .clear
capsuleGlass.isHidden = false
capsuleGlass.updateGlass()
cancelGlass.isHidden = false
cancelGlass.updateGlass()
} else {
// Solid color (Telegram inactive search)
capsuleView.backgroundColor = solidFill
capsuleGlass.isHidden = true
cancelGlass.isHidden = true
}
}
private func updateVisualState(animated: Bool) {
// Switch capsule background: solid glass
applyCapsuleBackground()
let updates = {
self.placeholderStack.alpha = self.isSearchActive ? 0 : 1
self.activeStack.alpha = self.isSearchActive ? 1 : 0
self.cancelButton.alpha = self.isSearchActive ? 1 : 0
self.cancelWidthConstraint.constant = self.isSearchActive ? 36 : 0
self.cancelWidthConstraint.constant = self.isSearchActive ? 44 : 0
self.capsuleTrailingConstraint.constant = self.isSearchActive ? -8 : 0
self.layoutIfNeeded()
}