diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailViewController.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailViewController.swift index 0779168..53c7cb1 100644 --- a/Rosetta/Features/Chats/ChatDetail/ChatDetailViewController.swift +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailViewController.swift @@ -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( diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift index 7429c0c..54a1b40 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift @@ -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? @@ -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) } diff --git a/Rosetta/Features/Chats/ChatList/ChatListSearchContent.swift b/Rosetta/Features/Chats/ChatList/ChatListSearchContent.swift index 57d6571..4224c24 100644 --- a/Rosetta/Features/Chats/ChatList/ChatListSearchContent.swift +++ b/Rosetta/Features/Chats/ChatList/ChatListSearchContent.swift @@ -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 diff --git a/Rosetta/Features/Chats/ChatList/UIKit/ChatListUIKitView.swift b/Rosetta/Features/Chats/ChatList/UIKit/ChatListUIKitView.swift index e0ae10f..cd91582 100644 --- a/Rosetta/Features/Chats/ChatList/UIKit/ChatListUIKitView.swift +++ b/Rosetta/Features/Chats/ChatList/UIKit/ChatListUIKitView.swift @@ -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() }