diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailViewController.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailViewController.swift index d49e558..0779168 100644 --- a/Rosetta/Features/Chats/ChatDetail/ChatDetailViewController.swift +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailViewController.swift @@ -1071,6 +1071,7 @@ private final class ChatDetailTitlePill: UIControl { private let glassView = TelegramGlassUIView(frame: .zero) private let titleLabel = UILabel() private let subtitleLabel = UILabel() + private let typingDotsView = TypingDotsView() private let route: ChatRoute private weak var viewModel: ChatDetailViewModel? @@ -1102,6 +1103,10 @@ private final class ChatDetailTitlePill: UIControl { subtitleLabel.textAlignment = .center subtitleLabel.lineBreakMode = .byTruncatingTail addSubview(subtitleLabel) + + typingDotsView.isUserInteractionEnabled = false + typingDotsView.isHidden = true + addSubview(typingDotsView) } private func observeChanges() { @@ -1167,13 +1172,27 @@ private final class ChatDetailTitlePill: UIControl { } subtitleLabel.text = subtitle subtitleLabel.textColor = subtitleColor + + // Show/hide typing dots animation + let isTypingActive = (viewModel?.isTyping == true) + || !(viewModel?.typingSenderNames ?? []).isEmpty + if isTypingActive { + typingDotsView.dotColor = subtitleColor + typingDotsView.isHidden = false + typingDotsView.startAnimating() + } else { + typingDotsView.isHidden = true + typingDotsView.stopAnimating() + } + setNeedsLayout() } /// Returns the natural content width (max of title/subtitle label widths). func contentWidth() -> CGFloat { let titleW = titleLabel.intrinsicContentSize.width let subtitleW = subtitleLabel.intrinsicContentSize.width - return max(titleW, subtitleW) + let dotsExtra: CGFloat = typingDotsView.isHidden ? 0 : 24 + return max(titleW, subtitleW + dotsExtra) } override func layoutSubviews() { @@ -1195,7 +1214,24 @@ private final class ChatDetailTitlePill: UIControl { let totalH = titleH + spacing + subtitleH let topY = (bounds.height - totalH) / 2 titleLabel.frame = CGRect(x: hPad, y: topY, width: contentW, height: titleH) - subtitleLabel.frame = CGRect(x: hPad, y: topY + titleH + spacing, width: contentW, height: subtitleH) + + let subtitleY = topY + titleH + spacing + if !typingDotsView.isHidden { + // Dots (24×14) + subtitle text, centered horizontally + let dotsW: CGFloat = 24 + let dotsH: CGFloat = 14 + let textW = subtitleLabel.intrinsicContentSize.width + let totalW = dotsW + textW + let startX = (bounds.width - totalW) / 2 + + typingDotsView.frame = CGRect(x: startX, y: subtitleY + (subtitleH - dotsH) / 2 + 1, width: dotsW, height: dotsH) + subtitleLabel.frame = CGRect(x: startX + dotsW, y: subtitleY, width: textW, height: subtitleH) + subtitleLabel.textAlignment = .left + } else { + typingDotsView.frame = .zero + subtitleLabel.frame = CGRect(x: hPad, y: subtitleY, width: contentW, height: subtitleH) + subtitleLabel.textAlignment = .center + } } else { titleLabel.frame = CGRect(x: hPad, y: 0, width: contentW, height: bounds.height) } diff --git a/Rosetta/Features/Chats/ChatDetail/NativeSkeletonView.swift b/Rosetta/Features/Chats/ChatDetail/NativeSkeletonView.swift index f4bef72..94efbcf 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeSkeletonView.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeSkeletonView.swift @@ -53,22 +53,22 @@ final class NativeSkeletonView: UIView { BubbleSpec(widthFrac: 0.50, height: 44, outgoing: false), ] - // Groups: all incoming (Telegram parity) + // Groups: mix of incoming/outgoing (like a real group conversation) private static let groupSpecs: [BubbleSpec] = [ BubbleSpec(widthFrac: 0.47, height: 44, outgoing: false), BubbleSpec(widthFrac: 0.58, height: 44, outgoing: false), + BubbleSpec(widthFrac: 0.50, height: 44, outgoing: true), BubbleSpec(widthFrac: 0.69, height: 60, outgoing: false), + BubbleSpec(widthFrac: 0.45, height: 44, outgoing: true), BubbleSpec(widthFrac: 0.47, height: 44, outgoing: false), BubbleSpec(widthFrac: 0.58, height: 44, outgoing: false), + BubbleSpec(widthFrac: 0.55, height: 44, outgoing: true), BubbleSpec(widthFrac: 0.36, height: 60, outgoing: false), BubbleSpec(widthFrac: 0.47, height: 44, outgoing: false), - BubbleSpec(widthFrac: 0.36, height: 44, outgoing: false), + BubbleSpec(widthFrac: 0.40, height: 44, outgoing: true), BubbleSpec(widthFrac: 0.58, height: 44, outgoing: false), BubbleSpec(widthFrac: 0.69, height: 60, outgoing: false), - BubbleSpec(widthFrac: 0.58, height: 44, outgoing: false), - BubbleSpec(widthFrac: 0.36, height: 44, outgoing: false), - BubbleSpec(widthFrac: 0.47, height: 44, outgoing: false), - BubbleSpec(widthFrac: 0.58, height: 44, outgoing: false), + BubbleSpec(widthFrac: 0.50, height: 44, outgoing: true), ] private static let avatarSize: CGFloat = 38 @@ -99,6 +99,7 @@ final class NativeSkeletonView: UIView { private let containerView = UIView() private var bubbleImageViews: [UIImageView] = [] private var avatarViews: [UIView] = [] + private var shimmerLayer: CALayer? private var shimmerGradients: [CAGradientLayer] = [] private var isShimmerRunning = false @@ -184,9 +185,12 @@ final class NativeSkeletonView: UIView { bubbleImageViews.append(iv) } - // Shimmer mask: use actual bubble path shape (not rect) + // Shimmer mask: inset path by 1pt so shimmer stays inside the raster bubble image + // (vector BezierPath vs 9-slice raster image have slightly different anti-aliased edges) + let maskInset: CGFloat = 1.0 + let maskRect = CGRect(origin: .zero, size: bubbleFrame.size).insetBy(dx: maskInset, dy: maskInset) let bubblePath = BubbleGeometryEngine.makeBezierPath( - in: CGRect(origin: .zero, size: bubbleFrame.size), + in: maskRect, mergeType: .none, outgoing: spec.outgoing, metrics: metrics diff --git a/Rosetta/Features/Chats/ChatList/ChatListSearchContent.swift b/Rosetta/Features/Chats/ChatList/ChatListSearchContent.swift index d14ab48..57d6571 100644 --- a/Rosetta/Features/Chats/ChatList/ChatListSearchContent.swift +++ b/Rosetta/Features/Chats/ChatList/ChatListSearchContent.swift @@ -194,14 +194,15 @@ private extension ChatListSearchContent { ) } } - // Desktop parity: search subtitle shows @username, not online/offline. + // Telegram parity: subtitle shows online status, not @username if !isSelf { - Text(user.username.isEmpty - ? "@\(String(user.publicKey.prefix(10)))..." - : "@\(user.username)" - ) + let dialog = DialogRepository.shared.dialogs[user.publicKey] + let isOnline = dialog?.isOnline ?? false + Text(isOnline ? "online" : "last seen recently") .font(.system(size: 13)) - .foregroundStyle(RosettaColors.Adaptive.textSecondary) + .foregroundStyle(isOnline + ? RosettaColors.primaryBlue + : RosettaColors.Adaptive.textSecondary) .lineLimit(1) } } diff --git a/Rosetta/Features/Chats/ChatList/UIKit/ChatListUIKitView.swift b/Rosetta/Features/Chats/ChatList/UIKit/ChatListUIKitView.swift index 2aa72cb..e0ae10f 100644 --- a/Rosetta/Features/Chats/ChatList/UIKit/ChatListUIKitView.swift +++ b/Rosetta/Features/Chats/ChatList/UIKit/ChatListUIKitView.swift @@ -88,6 +88,10 @@ final class ChatListRootViewController: UIViewController, UINavigationController private var searchResultUsersByKey: [String: SearchUser] = [:] private let searchTopSpacing: CGFloat = 10 private let searchBottomSpacing: CGFloat = 5 + + // Search overlay (shown when search is active) + private var searchOverlayView: UIView? + private var searchContentHosting: UIHostingController? private let searchHeaderHeight: CGFloat = 44 private var searchChromeHeight: CGFloat { searchTopSpacing + searchHeaderHeight + searchBottomSpacing @@ -218,6 +222,9 @@ final class ChatListRootViewController: UIViewController, UINavigationController editButtonControl.isUserInteractionEnabled = true rightButtonsControl.isUserInteractionEnabled = true toolbarTitleView.isUserInteractionEnabled = true + // Restore search bar position and tear down overlay + searchHeaderTopConstraint?.constant = headerBarHeight + searchTopSpacing + teardownSearchOverlayImmediately() } } @@ -295,6 +302,7 @@ final class ChatListRootViewController: UIViewController, UINavigationController self.currentSearchQuery = query self.viewModel.setSearchQuery(query) self.renderList() + self.updateSearchOverlayContent() } searchHeaderView.onActiveChanged = { [weak self] active in @@ -302,6 +310,12 @@ final class ChatListRootViewController: UIViewController, UINavigationController self.applySearchExpansion(1.0, animated: true) self.updateNavigationBarBlur(progress: active ? 1.0 : (1.0 - self.lastSearchExpansion)) self.animateToolbarForSearch(active: active) + self.animateSearchBarPosition(active: active) + if active { + self.showSearchOverlay() + } else { + self.hideSearchOverlay() + } self.onSearchActiveChanged?(active) } } @@ -469,6 +483,119 @@ final class ChatListRootViewController: UIViewController, UINavigationController toolbarTitleView.isUserInteractionEnabled = !active } + // MARK: - Search Bar Position Animation + + /// Telegram: search bar moves to just below status bar when active + private func animateSearchBarPosition(active: Bool) { + let targetTop: CGFloat = active + ? searchTopSpacing // just below status bar + : headerBarHeight + searchTopSpacing // below custom toolbar + searchHeaderTopConstraint?.constant = targetTop + + UIView.animate( + withDuration: 0.5, + delay: 0, + usingSpringWithDamping: 0.78, + initialSpringVelocity: 0, + options: [.beginFromCurrentState], + animations: { self.view.layoutIfNeeded() } + ) + } + + // MARK: - Search Overlay + + private func showSearchOverlay() { + guard searchOverlayView == nil else { return } + + let bg = UIView() + bg.backgroundColor = UIColor(RosettaColors.Adaptive.background) + bg.alpha = 0 + bg.translatesAutoresizingMaskIntoConstraints = false + bg.layer.zPosition = 45 + view.insertSubview(bg, belowSubview: searchHeaderView) + + NSLayoutConstraint.activate([ + bg.topAnchor.constraint(equalTo: searchHeaderView.bottomAnchor, constant: searchBottomSpacing), + bg.leadingAnchor.constraint(equalTo: view.leadingAnchor), + bg.trailingAnchor.constraint(equalTo: view.trailingAnchor), + bg.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + let content = makeSearchContentView() + let hosting = UIHostingController(rootView: AnyView(content)) + hosting.view.backgroundColor = .clear + hosting.view.translatesAutoresizingMaskIntoConstraints = false + + addChild(hosting) + bg.addSubview(hosting.view) + hosting.didMove(toParent: self) + + NSLayoutConstraint.activate([ + hosting.view.topAnchor.constraint(equalTo: bg.topAnchor), + hosting.view.leadingAnchor.constraint(equalTo: bg.leadingAnchor), + hosting.view.trailingAnchor.constraint(equalTo: bg.trailingAnchor), + hosting.view.bottomAnchor.constraint(equalTo: bg.bottomAnchor), + ]) + + searchOverlayView = bg + searchContentHosting = hosting + + UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseOut) { + bg.alpha = 1 + } + } + + private func hideSearchOverlay() { + guard let overlay = searchOverlayView else { return } + + UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseOut, animations: { + overlay.alpha = 0 + }) { [weak self] _ in + guard let self else { return } + self.searchContentHosting?.willMove(toParent: nil) + self.searchContentHosting?.view.removeFromSuperview() + self.searchContentHosting?.removeFromParent() + self.searchContentHosting = nil + overlay.removeFromSuperview() + self.searchOverlayView = nil + } + } + + private func updateSearchOverlayContent() { + guard let hosting = searchContentHosting else { return } + hosting.rootView = AnyView(makeSearchContentView()) + } + + private func makeSearchContentView() -> some View { + ChatListSearchContent( + searchText: currentSearchQuery, + viewModel: viewModel, + onSelectRecent: { [weak self] query in + guard let self else { return } + self.searchHeaderView.setQueryFromOutside(query) + }, + onOpenDialog: { [weak self] route in + guard let self else { return } + self.searchHeaderView.endSearch(animated: false, clearText: true) + self.animateSearchBarPosition(active: false) + self.hideSearchOverlay() + self.animateToolbarForSearch(active: false) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in + self?.openChat(route: route) + } + } + ) + } + + private func teardownSearchOverlayImmediately() { + searchContentHosting?.willMove(toParent: nil) + searchContentHosting?.view.removeFromSuperview() + searchContentHosting?.removeFromParent() + searchContentHosting = nil + searchOverlayView?.removeFromSuperview() + searchOverlayView = nil + } + private func applySearchExpansion(_ expansion: CGFloat, animated: Bool) { let clamped = max(0.0, min(1.0, expansion)) if searchHeaderView.isSearchActive && clamped < 0.999 { @@ -1019,6 +1146,11 @@ private final class ChatListSearchHeaderView: UIView, UITextFieldDelegate { setSearchActive(false, animated: animated, clearText: clearText) } + /// Called from search overlay when user selects a recent search query + func setQueryFromOutside(_ text: String) { + setQueryText(text) + } + /// Telegram-style expansion animation: /// - Corner radius scales with height (pill shape maintained) /// - Text/icon alpha: invisible until 77% expanded, then ramps to 1.0 @@ -1084,10 +1216,13 @@ private final class ChatListSearchHeaderView: UIView, UITextFieldDelegate { activeStack.addArrangedSubview(inlineClearButton) capsuleView.addSubview(activeStack) + // Telegram-style circular X button (replaces "Cancel" text) cancelButton.translatesAutoresizingMaskIntoConstraints = false - cancelButton.setTitle("Cancel", for: .normal) - cancelButton.titleLabel?.font = .systemFont(ofSize: 17) - cancelButton.contentHorizontalAlignment = .right + let xConfig = UIImage.SymbolConfiguration(pointSize: 14, weight: .semibold) + cancelButton.setImage(UIImage(systemName: "xmark", withConfiguration: xConfig), for: .normal) + cancelButton.setTitle(nil, for: .normal) + cancelButton.layer.cornerRadius = 18 + cancelButton.clipsToBounds = true cancelButton.addTarget(self, action: #selector(handleCancelTapped), for: .touchUpInside) addSubview(cancelButton) @@ -1100,10 +1235,11 @@ private final class ChatListSearchHeaderView: UIView, UITextFieldDelegate { capsuleView.leadingAnchor.constraint(equalTo: leadingAnchor), capsuleView.topAnchor.constraint(equalTo: topAnchor), capsuleView.bottomAnchor.constraint(equalTo: bottomAnchor), - capsuleView.trailingAnchor.constraint(equalTo: cancelButton.leadingAnchor), + capsuleView.trailingAnchor.constraint(equalTo: cancelButton.leadingAnchor, constant: -8), cancelButton.trailingAnchor.constraint(equalTo: trailingAnchor), cancelButton.centerYAnchor.constraint(equalTo: centerYAnchor), + cancelButton.heightAnchor.constraint(equalToConstant: 36), cancelWidthConstraint, placeholderStack.centerXAnchor.constraint(equalTo: capsuleView.centerXAnchor), @@ -1136,7 +1272,11 @@ private final class ChatListSearchHeaderView: UIView, UITextFieldDelegate { // inputTextColor: dark #ffffff, light #000000 textField.textColor = isDark ? .white : .black inlineClearButton.tintColor = placeholderColor - cancelButton.setTitleColor(UIColor(RosettaColors.primaryBlue), for: .normal) + // 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) + cancelButton.tintColor = isDark ? .white : .black } private func updateVisualState(animated: Bool) { @@ -1144,7 +1284,7 @@ private final class ChatListSearchHeaderView: UIView, UITextFieldDelegate { 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 ? 64 : 0 + self.cancelWidthConstraint.constant = self.isSearchActive ? 36 : 0 self.layoutIfNeeded() }