From 69ac9cd270d6061ebc6a2aff1dd4c1ef90e97d37 Mon Sep 17 00:00:00 2001 From: senseiGai Date: Mon, 13 Apr 2026 23:34:54 +0500 Subject: [PATCH] =?UTF-8?q?=D0=91=D0=B0=D0=BD=D0=BD=D0=B5=D1=80=20Telegram?= =?UTF-8?q?-=D0=BF=D0=B0=D1=80=D0=B8=D1=82=D0=B5=D1=82=D0=B0=20=D0=B8=20?= =?UTF-8?q?=D0=BF=D1=80=D1=8F=D0=BC=D0=BE=D0=B9=20=D0=BF=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D1=85=D0=BE=D0=B4=20=D0=B2=20=D1=87=D0=B0=D1=82=20=D0=BF=D0=BE?= =?UTF-8?q?=20=D1=82=D0=B0=D0=BF=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../xcschemes/xcschememanagement.plist | 4 +- .../Data/Repositories/DialogRepository.swift | 1 + .../Core/Services/InAppBannerManager.swift | 233 ++++++- Rosetta/Core/Services/SessionManager.swift | 11 +- .../Components/InAppBannerView.swift | 622 ++++++++++++------ .../Chats/ChatDetail/ChatDetailView.swift | 21 +- .../Chats/ChatDetail/NativeMessageList.swift | 5 +- .../Chats/ChatList/ChatListView.swift | 30 +- .../Chats/ChatList/RequestChatsView.swift | 5 +- .../Chats/ChatList/UIKit/ChatListCell.swift | 16 +- .../UIKit/ChatListCollectionController.swift | 32 +- .../Settings/SettingsViewController.swift | 242 ++++++- Rosetta/RosettaApp.swift | 47 +- RosettaTests/ChatListBottomInsetTests.swift | 115 ++++ fastlane/Appfile | 4 - fastlane/Fastfile | 104 --- fastlane/README.md | 56 -- 17 files changed, 1104 insertions(+), 444 deletions(-) create mode 100644 RosettaTests/ChatListBottomInsetTests.swift delete mode 100644 fastlane/Appfile delete mode 100644 fastlane/Fastfile delete mode 100644 fastlane/README.md diff --git a/Rosetta.xcodeproj/xcuserdata/gaidartimirbaev.xcuserdatad/xcschemes/xcschememanagement.plist b/Rosetta.xcodeproj/xcuserdata/gaidartimirbaev.xcuserdatad/xcschemes/xcschememanagement.plist index 83ada60..9e60ee4 100644 --- a/Rosetta.xcodeproj/xcuserdata/gaidartimirbaev.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Rosetta.xcodeproj/xcuserdata/gaidartimirbaev.xcuserdatad/xcschemes/xcschememanagement.plist @@ -12,12 +12,12 @@ RosettaLiveActivityWidget.xcscheme_^#shared#^_ orderHint - 3 + 1 RosettaNotificationService.xcscheme_^#shared#^_ orderHint - 1 + 3 RosettaUITests.xcscheme_^#shared#^_ diff --git a/Rosetta/Core/Data/Repositories/DialogRepository.swift b/Rosetta/Core/Data/Repositories/DialogRepository.swift index b388c2d..1c77f86 100644 --- a/Rosetta/Core/Data/Repositories/DialogRepository.swift +++ b/Rosetta/Core/Data/Repositories/DialogRepository.swift @@ -166,6 +166,7 @@ final class DialogRepository { case .avatar: lastMessageText = "Avatar" case .messages: lastMessageText = "Forwarded message" case .call: lastMessageText = "Call" + case .voice: lastMessageText = "Voice message" @unknown default: lastMessageText = "Attachment" } } else if textIsEmpty { diff --git a/Rosetta/Core/Services/InAppBannerManager.swift b/Rosetta/Core/Services/InAppBannerManager.swift index 583f22a..91bea77 100644 --- a/Rosetta/Core/Services/InAppBannerManager.swift +++ b/Rosetta/Core/Services/InAppBannerManager.swift @@ -1,4 +1,3 @@ -import Combine import Foundation import UIKit @@ -9,13 +8,14 @@ import UIKit /// Queue: one banner at a time. New banner replaces current. /// Auto-dismiss: 5 seconds. Swipe-up or tap dismisses immediately. @MainActor -final class InAppBannerManager: ObservableObject { +final class InAppBannerManager { static let shared = InAppBannerManager() - @Published var currentBanner: BannerData? - private var dismissTask: Task? + private var dismissGeneration: UInt64 = 0 + private var overlayWindow: InAppBannerWindow? + private weak var overlayController: InAppBannerOverlayViewController? /// Notification posted by SessionManager.processIncomingMessage /// when a foreground message should trigger an in-app banner. @@ -37,25 +37,126 @@ final class InAppBannerManager: ObservableObject { func show(_ data: BannerData) { // Replace current banner. dismissTask?.cancel() - currentBanner = data + dismissTask = nil + dismissGeneration &+= 1 + guard let controller = ensureOverlayController() else { return } - // Auto-dismiss after 5 seconds (Telegram parity). - dismissTask = Task { - try? await Task.sleep(for: .seconds(5)) - guard !Task.isCancelled else { return } - dismiss() - } + controller.presentBanner( + data: data, + onTap: { [weak self] in + self?.openChat(with: data) + }, + onExpand: { [weak self] in + self?.openChat(with: data) + }, + onDismiss: { [weak self] in + self?.dismiss(animated: false) + }, + onDragBegan: { [weak self] in + self?.cancelAutoDismiss() + }, + onDragEndedWithoutAction: { [weak self] in + self?.resumeAutoDismiss() + } + ) + + scheduleAutoDismiss(for: dismissGeneration) } - func dismiss() { + func dismiss(animated: Bool = true) { dismissTask?.cancel() - currentBanner = nil + dismissTask = nil + dismissGeneration &+= 1 + + guard let controller = overlayController else { + teardownOverlayIfNeeded() + return + } + + controller.dismissBanner(animated: animated) { [weak self] in + self?.teardownOverlayIfNeeded() + } } /// Cancel auto-dismiss timer (e.g., during active pan gesture). /// Telegram: cancels timeout when abs(translation) > 4pt. func cancelAutoDismiss() { dismissTask?.cancel() + dismissTask = nil + } + + /// Resume auto-dismiss timer after a gesture ends without dismiss/expand. + /// Telegram resets timeout when the banner springs back to origin. + func resumeAutoDismiss() { + guard overlayController?.hasBanner == true else { return } + scheduleAutoDismiss(for: dismissGeneration) + } + + private func scheduleAutoDismiss(for generation: UInt64) { + dismissTask?.cancel() + // Auto-dismiss after 5 seconds (Telegram parity). + dismissTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(5)) + guard !Task.isCancelled else { return } + await MainActor.run { + guard let self, generation == self.dismissGeneration else { return } + self.dismiss(animated: true) + } + } + } + + private func openChat(with data: BannerData) { + dismiss(animated: false) + let route = ChatRoute( + publicKey: data.senderKey, + title: data.senderName, + username: "", + verified: data.verified + ) + AppDelegate.pendingChatRoute = route + AppDelegate.pendingChatRouteTimestamp = Date() + NotificationCenter.default.post( + name: .openChatFromNotification, + object: route + ) + } + + private func ensureOverlayController() -> InAppBannerOverlayViewController? { + if let controller = overlayController { + if overlayWindow?.isHidden == true { + overlayWindow?.isHidden = false + } + return controller + } + + guard let scene = activeWindowScene else { return nil } + + let window = InAppBannerWindow(windowScene: scene) + window.backgroundColor = .clear + window.windowLevel = .statusBar + 1 + + let controller = InAppBannerOverlayViewController() + window.rootViewController = controller + window.isHidden = false + + overlayWindow = window + overlayController = controller + return controller + } + + private var activeWindowScene: UIWindowScene? { + let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene } + if let active = scenes.first(where: { $0.activationState == .foregroundActive }) { + return active + } + return scenes.first(where: { $0.activationState == .foregroundInactive }) + } + + private func teardownOverlayIfNeeded() { + guard overlayController?.hasBanner != true else { return } + overlayWindow?.isHidden = true + overlayWindow = nil + overlayController = nil } // MARK: - Data @@ -70,3 +171,109 @@ final class InAppBannerManager: ObservableObject { var id: String { senderKey } } } + +private final class InAppBannerWindow: UIWindow {} + +private final class InAppBannerOverlayViewController: UIViewController { + + private let passthroughView = InAppBannerPassthroughView() + private var bannerView: InAppBannerView? + + var hasBanner: Bool { + bannerView != nil + } + + override func loadView() { + view = passthroughView + view.backgroundColor = .clear + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + view.isUserInteractionEnabled = true + } + + func presentBanner( + data: InAppBannerManager.BannerData, + onTap: @escaping () -> Void, + onExpand: @escaping () -> Void, + onDismiss: @escaping () -> Void, + onDragBegan: @escaping () -> Void, + onDragEndedWithoutAction: @escaping () -> Void + ) { + dismissBanner(animated: false) {} + + let banner = InAppBannerView(frame: .zero) + banner.translatesAutoresizingMaskIntoConstraints = false + banner.applyCallbacks( + onTap: onTap, + onDismiss: onDismiss, + onExpand: onExpand, + onDragBegan: onDragBegan, + onDragEndedWithoutAction: onDragEndedWithoutAction + ) + banner.configure( + senderName: data.senderName, + messagePreview: data.messagePreview, + senderKey: data.senderKey, + isGroup: data.isGroup + ) + + view.addSubview(banner) + NSLayoutConstraint.activate([ + banner.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 8), + banner.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -8), + banner.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: InAppBannerView.preferredTopInset), + banner.heightAnchor.constraint(equalToConstant: InAppBannerView.preferredHeight), + ]) + + view.layoutIfNeeded() + bannerView = banner + passthroughView.bannerView = banner + banner.animateIn() + } + + func dismissBanner(animated: Bool, completion: @escaping () -> Void) { + guard let banner = bannerView else { + completion() + return + } + + let remove = { [weak self, weak banner] in + guard let self else { + completion() + return + } + banner?.removeFromSuperview() + if self.bannerView === banner { + self.bannerView = nil + } + self.passthroughView.bannerView = nil + completion() + } + + if animated { + banner.animateOut(completion: remove) + } else { + remove() + } + } +} + +private final class InAppBannerPassthroughView: UIView { + + weak var bannerView: UIView? + + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + guard let bannerView else { return false } + let pointInBanner = convert(point, to: bannerView) + return bannerView.bounds.contains(pointInBanner) + } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + guard let bannerView else { return nil } + let pointInBanner = convert(point, to: bannerView) + guard bannerView.bounds.contains(pointInBanner) else { return nil } + return bannerView.hitTest(pointInBanner, with: event) + } +} diff --git a/Rosetta/Core/Services/SessionManager.swift b/Rosetta/Core/Services/SessionManager.swift index 3aed02e..6667034 100644 --- a/Rosetta/Core/Services/SessionManager.swift +++ b/Rosetta/Core/Services/SessionManager.swift @@ -1903,7 +1903,16 @@ final class SessionManager { let senderName = dialog?.opponentTitle ?? "" let preview: String = { if !text.isEmpty { return text } - if !processedPacket.attachments.isEmpty { return "Photo" } + if let firstAtt = processedPacket.attachments.first { + switch firstAtt.type { + case .image: return "Photo" + case .file: return "File" + case .voice: return "Voice message" + case .messages: return "Forwarded message" + case .call: return "Call" + default: return "Attachment" + } + } return "New message" }() let bannerData = InAppBannerManager.BannerData( diff --git a/Rosetta/DesignSystem/Components/InAppBannerView.swift b/Rosetta/DesignSystem/Components/InAppBannerView.swift index d07d1e7..3b1b777 100644 --- a/Rosetta/DesignSystem/Components/InAppBannerView.swift +++ b/Rosetta/DesignSystem/Components/InAppBannerView.swift @@ -1,213 +1,453 @@ -import SwiftUI import UIKit -// MARK: - In-App Notification Banner (Telegram parity) +// MARK: - In-App Notification Banner (UIKit only) -/// Telegram-style in-app notification banner shown when a message arrives -/// while the app is in foreground and the user is NOT in that chat. -/// -/// Specs (from Telegram iOS `ChatMessageNotificationItem.swift` + `NotificationItemContainerNode.swift`): -/// - Panel: 74pt min height, 24pt corner radius, 8pt horizontal margin -/// - Avatar: 54pt circle, 12pt left inset, 23pt avatar-to-text spacing -/// - Title: semibold 16pt, 1 line -/// - Message: regular 16pt, max 2 lines -/// - Text color: white (dark) / black (light) — adapts to colorScheme -/// - Background: TelegramGlass (CABackdropLayer / UIGlassEffect) -/// - Slide from top: 0.4s, auto-dismiss: 5s -/// - Swipe up to dismiss (5pt / 200pt/s threshold) -/// - Swipe down to expand (open chat, 20pt / 300pt/s threshold) -/// - Haptic feedback on drag -struct InAppBannerView: View { - let senderName: String - let messagePreview: String - let senderKey: String - let isGroup: Bool - let onTap: () -> Void - let onDismiss: () -> Void - let onExpand: () -> Void - let onDragBegan: () -> Void +/// Telegram-style in-app notification banner implemented fully in UIKit. +/// No SwiftUI bridge is used in this view. +final class InAppBannerView: UIView { - @Environment(\.colorScheme) private var colorScheme - - @State private var dragOffset: CGFloat = 0 - @State private var hapticPrepared = false - @State private var hasExpandedHaptic = false - @State private var dragCancelledTimeout = false - - private let panelHeight: CGFloat = 74 - private let cornerRadius: CGFloat = 24 - private let avatarSize: CGFloat = 54 - private let horizontalMargin: CGFloat = 8 - - private var textColor: Color { - colorScheme == .dark ? .white : .black + private enum Metrics { + // Telegram in-app notifications currently use compact layout. + static let panelHeight: CGFloat = 64 + static let cornerRadius: CGFloat = 24 + static let avatarSize: CGFloat = 40 + static let avatarLeading: CGFloat = 12 + // Telegram compact reference: leftInset = imageSize + imageSpacing = 40 + 22 = 62. + static let textLeading: CGFloat = 62 + static let trailingInset: CGFloat = 10 + static let verticalTextSpacing: CGFloat = 1 + static let topInset: CGFloat = 6 } - var body: some View { - HStack(spacing: 23) { - avatarView - VStack(alignment: .leading, spacing: 1) { - Text(senderName) - .font(.system(size: 16, weight: .semibold)) - .foregroundStyle(textColor) - .lineLimit(1) - Text(messagePreview) - .font(.system(size: 16)) - .foregroundStyle(textColor) - .lineLimit(2) + static let preferredHeight: CGFloat = Metrics.panelHeight + static let preferredTopInset: CGFloat = Metrics.topInset + + private let glassView = TelegramGlassUIView(frame: .zero) + + private let avatarBackgroundView = UIView() + private let avatarTintOverlayView = UIView() + private let avatarImageView = UIImageView() + private let avatarInitialsLabel = UILabel() + private let groupIconView = UIImageView() + + private let titleLabel = UILabel() + private let messageLabel = UILabel() + + private var senderName: String = "" + private var messagePreview: String = "" + private var senderKey: String = "" + private var isGroup: Bool = false + + private var onTap: (() -> Void)? + private var onDismiss: (() -> Void)? + private var onExpand: (() -> Void)? + private var onDragBegan: (() -> Void)? + private var onDragEndedWithoutAction: (() -> Void)? + + private var hapticFeedback: UIImpactFeedbackGenerator? + private var cancelledTimeout = false + private var isAnimatingOut = false + private var isPanning = false + + private var willBeExpanded = false { + didSet { + guard willBeExpanded != oldValue else { return } + if hapticFeedback == nil { + hapticFeedback = UIImpactFeedbackGenerator(style: .medium) } - Spacer(minLength: 0) + hapticFeedback?.impactOccurred() } - .padding(.leading, 12) - .padding(.trailing, 10) - .frame(minHeight: panelHeight) - .frame(maxWidth: .infinity) - .glass(cornerRadius: cornerRadius) - .shadow(color: .black.opacity(0.04), radius: 40, x: 0, y: 1) - .padding(.horizontal, horizontalMargin) - .offset(y: dragOffset) - .gesture( - DragGesture(minimumDistance: 5) - .onChanged { value in - let translation = value.translation.height - - // Haptic: prepare when drag begins (Telegram: abs > 1pt). - if abs(translation) > 1 && !hapticPrepared { - UIImpactFeedbackGenerator(style: .medium).prepare() - hapticPrepared = true - } - - // Cancel auto-dismiss timer (Telegram: abs > 4pt). - if abs(translation) > 4 && !dragCancelledTimeout { - dragCancelledTimeout = true - onDragBegan() - } - - // Haptic on expand threshold crossing (Telegram: bounds.minY < -24pt). - if translation > 24 && !hasExpandedHaptic { - hasExpandedHaptic = true - UIImpactFeedbackGenerator(style: .medium).impactOccurred() - } - - if translation < 0 { - // Dragging up — full 1:1 tracking. - dragOffset = translation - } else { - // Dragging down — logarithmic rubber-band (Telegram: 0.55/50 cap). - let delta = translation - dragOffset = (1.0 - (1.0 / ((delta * 0.55 / 50.0) + 1.0))) * 50.0 - } - } - .onEnded { value in - let velocity = value.predictedEndTranslation.height - value.translation.height - - // Reset haptic state. - hapticPrepared = false - hasExpandedHaptic = false - dragCancelledTimeout = false - - // Expand: pulled down > 20pt or fast downward velocity (Telegram: -20pt / 300pt/s). - if value.translation.height > 20 || velocity > 300 { - withAnimation(.easeInOut(duration: 0.3)) { - dragOffset = 0 - } - onExpand() - return - } - - // Dismiss: swiped up > 5pt or fast upward velocity (Telegram: 5pt / 200pt/s). - if value.translation.height < -5 || velocity < -200 { - withAnimation(.easeOut(duration: 0.4)) { - dragOffset = -200 - } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { - onDismiss() - } - } else { - // Spring back (Telegram: 0.3s easeInOut). - withAnimation(.easeInOut(duration: 0.3)) { - dragOffset = 0 - } - } - } - ) - .onTapGesture { - onTap() - } - .contentShape(Rectangle()) } - @ViewBuilder - private var avatarView: some View { - let colorIndex = Self.avatarColorIndex(for: senderName, publicKey: senderKey) - let pair = Self.avatarColors[colorIndex % Self.avatarColors.count] + override var intrinsicContentSize: CGSize { + CGSize(width: UIView.noIntrinsicMetric, height: Metrics.panelHeight) + } + + override init(frame: CGRect) { + super.init(frame: frame) + setupView() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func applyCallbacks( + onTap: @escaping () -> Void, + onDismiss: @escaping () -> Void, + onExpand: @escaping () -> Void, + onDragBegan: @escaping () -> Void, + onDragEndedWithoutAction: @escaping () -> Void + ) { + self.onTap = onTap + self.onDismiss = onDismiss + self.onExpand = onExpand + self.onDragBegan = onDragBegan + self.onDragEndedWithoutAction = onDragEndedWithoutAction + } + + func configure(senderName: String, messagePreview: String, senderKey: String, isGroup: Bool) { + let senderChanged = self.senderName != senderName || self.senderKey != senderKey || self.isGroup != isGroup + let messageChanged = self.messagePreview != messagePreview + + self.senderName = senderName + self.messagePreview = messagePreview + self.senderKey = senderKey + self.isGroup = isGroup + + if senderChanged { + titleLabel.text = senderName + updateAvatar() + } + + if messageChanged { + messageLabel.text = messagePreview + } + + resetTransientTransformState() + setNeedsLayout() + } + + func animateIn() { + guard !isAnimatingOut else { return } + let startY = -(bounds.height + 24.0) + transform = CGAffineTransform(translationX: 0, y: startY) + UIView.animate( + withDuration: 0.4, + delay: 0, + options: [.curveEaseInOut, .beginFromCurrentState, .allowUserInteraction] + ) { + self.transform = .identity + } + } + + func animateOut(completion: @escaping () -> Void) { + guard !isAnimatingOut else { + completion() + return + } + isAnimatingOut = true + + let targetY = -(bounds.height + 24.0) + UIView.animate( + withDuration: 0.4, + delay: 0, + options: [.curveEaseOut, .beginFromCurrentState, .allowUserInteraction] + ) { + self.transform = CGAffineTransform(translationX: 0, y: targetY) + } completion: { _ in + self.isAnimatingOut = false + completion() + } + } + + override func layoutSubviews() { + super.layoutSubviews() + + guard bounds.width > 0, bounds.height > 0 else { return } + + glassView.frame = bounds + + let avatarY = floor((bounds.height - Metrics.avatarSize) / 2.0) + let avatarFrame = CGRect( + x: Metrics.avatarLeading, + y: avatarY, + width: Metrics.avatarSize, + height: Metrics.avatarSize + ) + + avatarBackgroundView.frame = avatarFrame + avatarTintOverlayView.frame = avatarBackgroundView.bounds + avatarImageView.frame = avatarFrame + avatarInitialsLabel.frame = avatarFrame + groupIconView.frame = avatarFrame + + let leftInset = Metrics.textLeading + let textWidth = max(0, bounds.width - leftInset - Metrics.trailingInset) + + let titleSize = titleLabel.sizeThatFits( + CGSize(width: textWidth, height: CGFloat.greatestFiniteMagnitude) + ) + let titleHeight = ceil(min(titleSize.height, titleLabel.font.lineHeight)) + + let rawMessageSize = messageLabel.sizeThatFits( + CGSize(width: textWidth, height: CGFloat.greatestFiniteMagnitude) + ) + let maxMessageHeight = ceil(messageLabel.font.lineHeight * 2.0) + let messageHeight = ceil(min(rawMessageSize.height, maxMessageHeight)) + + let totalTextHeight = titleHeight + Metrics.verticalTextSpacing + messageHeight + let titleY = floor((bounds.height - totalTextHeight) / 2.0) + + titleLabel.frame = CGRect(x: leftInset, y: titleY, width: textWidth, height: titleHeight) + messageLabel.frame = CGRect( + x: leftInset, + y: titleLabel.frame.maxY + Metrics.verticalTextSpacing, + width: textWidth, + height: messageHeight + ) + } + + override func didMoveToWindow() { + super.didMoveToWindow() + if window == nil { + resetTransientTransformState() + } + } + + // MARK: - Setup + + private func setupView() { + backgroundColor = .clear + isOpaque = false + clipsToBounds = true + layer.cornerCurve = .continuous + layer.cornerRadius = Metrics.cornerRadius + + glassView.backgroundColor = .clear + glassView.fixedCornerRadius = Metrics.cornerRadius + glassView.isUserInteractionEnabled = false + addSubview(glassView) + + avatarBackgroundView.clipsToBounds = true + avatarBackgroundView.layer.cornerRadius = Metrics.avatarSize / 2.0 + addSubview(avatarBackgroundView) + + avatarTintOverlayView.clipsToBounds = true + avatarTintOverlayView.layer.cornerRadius = Metrics.avatarSize / 2.0 + avatarBackgroundView.addSubview(avatarTintOverlayView) + + avatarImageView.contentMode = .scaleAspectFill + avatarImageView.clipsToBounds = true + avatarImageView.layer.cornerRadius = Metrics.avatarSize / 2.0 + addSubview(avatarImageView) + + avatarInitialsLabel.textAlignment = .center + avatarInitialsLabel.font = .systemFont(ofSize: 20, weight: .bold) + addSubview(avatarInitialsLabel) + + groupIconView.contentMode = .center + addSubview(groupIconView) + + titleLabel.font = .systemFont(ofSize: 15, weight: .semibold) + titleLabel.numberOfLines = 1 + titleLabel.lineBreakMode = .byTruncatingTail + addSubview(titleLabel) + + messageLabel.font = .systemFont(ofSize: 15, weight: .regular) + messageLabel.numberOfLines = 2 + messageLabel.lineBreakMode = .byTruncatingTail + addSubview(messageLabel) + + applyTextColors() + + if #available(iOS 17.0, *) { + registerForTraitChanges([UITraitUserInterfaceStyle.self]) { (self: Self, _) in + self.applyTextColors() + self.updateAvatar() + } + } + + let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTapGesture(_:))) + tapRecognizer.cancelsTouchesInView = false + let panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handlePanGesture(_:))) + panRecognizer.delaysTouchesBegan = false + panRecognizer.delaysTouchesEnded = false + panRecognizer.cancelsTouchesInView = false + + // Prevent tap from firing after tiny drags. + tapRecognizer.require(toFail: panRecognizer) + + addGestureRecognizer(tapRecognizer) + addGestureRecognizer(panRecognizer) + } + + private func applyTextColors() { + let isDark = traitCollection.userInterfaceStyle == .dark + let color = isDark ? UIColor.white : UIColor.black + titleLabel.textColor = color + messageLabel.textColor = color + } + + private func updateAvatar() { + let avatarIndex = RosettaColors.avatarColorIndex(for: senderName, publicKey: senderKey) + let tintColor = RosettaColors.avatarColor(for: avatarIndex) + let textColor = RosettaColors.avatarTextColor(for: avatarIndex) if isGroup { - // Group: solid tint circle + person.2.fill (ChatRowView parity). - ZStack { - Circle().fill(Color(hex: UInt(pair.tint))) - Image(systemName: "person.2.fill") - .font(.system(size: 20, weight: .medium)) - .foregroundStyle(.white.opacity(0.9)) - } - .frame(width: avatarSize, height: avatarSize) + avatarImageView.isHidden = true + avatarInitialsLabel.isHidden = true + avatarTintOverlayView.isHidden = true + + avatarBackgroundView.isHidden = false + avatarBackgroundView.backgroundColor = tintColor + + groupIconView.isHidden = false + groupIconView.tintColor = UIColor.white.withAlphaComponent(0.9) + groupIconView.image = UIImage(systemName: "person.2.fill")?.withConfiguration( + UIImage.SymbolConfiguration(pointSize: 16, weight: .medium) + ) + return + } + + groupIconView.isHidden = true + + if let image = AvatarRepository.shared.loadAvatar(publicKey: senderKey) { + avatarImageView.image = image + avatarImageView.isHidden = false + avatarBackgroundView.isHidden = true + avatarInitialsLabel.isHidden = true + avatarTintOverlayView.isHidden = true } else { - // Personal: Mantine "light" variant (AvatarView parity). - let avatarImage = AvatarRepository.shared.loadAvatar(publicKey: senderKey) - if let image = avatarImage { - Image(uiImage: image) - .resizable() - .scaledToFill() - .frame(width: avatarSize, height: avatarSize) - .clipShape(Circle()) - } else { - ZStack { - Circle().fill(colorScheme == .dark ? Color(hex: 0x1A1B1E) : Color(hex: 0xF1F3F5)) - Circle().fill(Color(hex: UInt(pair.tint)).opacity(0.15)) - Text(Self.initials(name: senderName, publicKey: senderKey)) - .font(.system(size: avatarSize * 0.38, weight: .bold, design: .rounded)) - .foregroundStyle(Color(hex: UInt(pair.text))) - } - .frame(width: avatarSize, height: avatarSize) + avatarImageView.isHidden = true + avatarBackgroundView.isHidden = false + avatarInitialsLabel.isHidden = false + avatarTintOverlayView.isHidden = false + + let isDark = traitCollection.userInterfaceStyle == .dark + avatarBackgroundView.backgroundColor = isDark + ? UIColor(red: 0x1A / 255.0, green: 0x1B / 255.0, blue: 0x1E / 255.0, alpha: 1.0) + : UIColor(red: 0xF1 / 255.0, green: 0xF3 / 255.0, blue: 0xF5 / 255.0, alpha: 1.0) + avatarTintOverlayView.backgroundColor = tintColor.withAlphaComponent(0.15) + avatarInitialsLabel.textColor = textColor + avatarInitialsLabel.text = RosettaColors.initials(name: senderName, publicKey: senderKey) + } + } + + // MARK: - Gestures + + @objc + private func handleTapGesture(_ recognizer: UITapGestureRecognizer) { + guard recognizer.state == .ended, !isPanning else { return } + emit(onTap) + } + + @objc + private func handlePanGesture(_ recognizer: UIPanGestureRecognizer) { + switch recognizer.state { + case .began: + cancelledTimeout = false + isPanning = false + willBeExpanded = false + hapticFeedback = nil + + case .changed: + if isAnimatingOut { return } + let translation = recognizer.translation(in: self) + if !isPanning && abs(translation.y) > 4.0 { + isPanning = true } - } - } + let displayedOffset = displayedOffset(for: translation.y) - // MARK: - Color helpers (duplicated from Colors.swift — NSE can't import main target) + if abs(translation.y) > 1.0 { + if hapticFeedback == nil { + hapticFeedback = UIImpactFeedbackGenerator(style: .medium) + } + hapticFeedback?.prepare() + } - private static let avatarColors: [(tint: UInt32, text: UInt32)] = [ - (0x228be6, 0x74c0fc), (0x15aabf, 0x66d9e8), (0xbe4bdb, 0xe599f7), - (0x40c057, 0x8ce99a), (0x4c6ef5, 0x91a7ff), (0x82c91e, 0xc0eb75), - (0xfd7e14, 0xffc078), (0xe64980, 0xfaa2c1), (0xfa5252, 0xffa8a8), - (0x12b886, 0x63e6be), (0x7950f2, 0xb197fc), - ] + setInteractiveOffset(displayedOffset) - private static func avatarColorIndex(for name: String, publicKey: String) -> Int { - let trimmed = name.trimmingCharacters(in: .whitespaces) - let input = trimmed.isEmpty ? String(publicKey.prefix(7)) : trimmed - var hash: Int32 = 0 - for char in input.unicodeScalars { - hash = (hash &<< 5) &- hash &+ Int32(truncatingIfNeeded: char.value) - } - let count = Int32(avatarColors.count) - var index = abs(hash) % count - if index < 0 { index += count } - return Int(index) - } + if !cancelledTimeout && abs(translation.y) > 4.0 { + cancelledTimeout = true + emit(onDragBegan) + } + + let expand = displayedOffset > 20.0 + if willBeExpanded != expand { + willBeExpanded = expand + } + + case .ended: + isPanning = false + let translation = recognizer.translation(in: self) + let velocity = recognizer.velocity(in: self) + let displayedOffset = displayedOffset(for: translation.y) + + if displayedOffset > 20.0 || velocity.y > 300.0 { + if !cancelledTimeout { + cancelledTimeout = true + emit(onDragBegan) + } + animateOffsetToOrigin(completion: { [weak self] in + self?.emit(self?.onExpand) + }) + } else if displayedOffset < -5.0 || velocity.y < -200.0 { + animateOut(completion: { [weak self] in + self?.emit(self?.onDismiss) + }) + } else { + if cancelledTimeout { + cancelledTimeout = false + emit(onDragEndedWithoutAction) + } + animateOffsetToOrigin(completion: nil) + } + + willBeExpanded = false + hapticFeedback = nil + + case .cancelled, .failed: + isPanning = false + if cancelledTimeout { + cancelledTimeout = false + emit(onDragEndedWithoutAction) + } + animateOffsetToOrigin(completion: nil) + willBeExpanded = false + hapticFeedback = nil - private static func initials(name: String, publicKey: String) -> String { - let words = name.trimmingCharacters(in: .whitespaces) - .split(whereSeparator: { $0.isWhitespace }) - .filter { !$0.isEmpty } - switch words.count { - case 0: - return publicKey.isEmpty ? "??" : String(publicKey.prefix(2)).uppercased() - case 1: - return String(words[0].prefix(2)).uppercased() default: - let first = words[0].first.map(String.init) ?? "" - let second = words[1].first.map(String.init) ?? "" - return (first + second).uppercased() + break + } + } + + // MARK: - Animation helpers + + private func animateOffsetToOrigin(completion: (() -> Void)?) { + guard !isAnimatingOut else { return } + guard abs(transform.ty) > 0.001 else { + completion?() + return + } + + UIView.animate( + withDuration: 0.3, + delay: 0, + options: [.curveEaseInOut, .beginFromCurrentState, .allowUserInteraction] + ) { + self.transform = .identity + } completion: { _ in + completion?() + } + } + + private func setInteractiveOffset(_ offsetY: CGFloat) { + CATransaction.begin() + CATransaction.setDisableActions(true) + transform = CGAffineTransform(translationX: 0, y: offsetY) + CATransaction.commit() + } + + private func displayedOffset(for translationY: CGFloat) -> CGFloat { + if translationY < 0 { + return translationY + } + return (1.0 - (1.0 / (((translationY) * 0.55 / 50.0) + 1.0))) * 50.0 + } + + private func resetTransientTransformState() { + isAnimatingOut = false + layer.removeAllAnimations() + transform = .identity + } + + private func emit(_ callback: (() -> Void)?) { + guard let callback else { return } + if Thread.isMainThread { + callback() + } else { + DispatchQueue.main.async(execute: callback) } } } diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift index cb30998..dbf7199 100644 --- a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift @@ -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 diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift index 646ab88..a513203 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift @@ -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 { diff --git a/Rosetta/Features/Chats/ChatList/ChatListView.swift b/Rosetta/Features/Chats/ChatList/ChatListView.swift index 10722a5..8391640 100644 --- a/Rosetta/Features/Chats/ChatList/ChatListView.swift +++ b/Rosetta/Features/Chats/ChatList/ChatListView.swift @@ -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. diff --git a/Rosetta/Features/Chats/ChatList/RequestChatsView.swift b/Rosetta/Features/Chats/ChatList/RequestChatsView.swift index 7916c12..c153a69 100644 --- a/Rosetta/Features/Chats/ChatList/RequestChatsView.swift +++ b/Rosetta/Features/Chats/ChatList/RequestChatsView.swift @@ -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([ diff --git a/Rosetta/Features/Chats/ChatList/UIKit/ChatListCell.swift b/Rosetta/Features/Chats/ChatList/UIKit/ChatListCell.swift index 0555d16..195c982 100644 --- a/Rosetta/Features/Chats/ChatList/UIKit/ChatListCell.swift +++ b/Rosetta/Features/Chats/ChatList/UIKit/ChatListCell.swift @@ -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? = 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, color: UIColor) { @@ -859,6 +872,7 @@ final class ChatListCell: UICollectionViewCell { wasMentionBadgeVisible = false badgeContainer.transform = .identity mentionImageView.transform = .identity + isSystemChat = false } // MARK: - Highlight diff --git a/Rosetta/Features/Chats/ChatList/UIKit/ChatListCollectionController.swift b/Rosetta/Features/Chats/ChatList/UIKit/ChatListCollectionController.swift index cf2702f..9db26e4 100644 --- a/Rosetta/Features/Chats/ChatList/UIKit/ChatListCollectionController.swift +++ b/Rosetta/Features/Chats/ChatList/UIKit/ChatListCollectionController.swift @@ -45,6 +45,16 @@ final class ChatListCollectionController: UIViewController { private var dataSource: UICollectionViewDiffableDataSource! private var cellRegistration: UICollectionView.CellRegistration! private var requestsCellRegistration: UICollectionView.CellRegistration! + 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 diff --git a/Rosetta/Features/Settings/SettingsViewController.swift b/Rosetta/Features/Settings/SettingsViewController.swift index 104d423..15fa870 100644 --- a/Rosetta/Features/Settings/SettingsViewController.swift +++ b/Rosetta/Features/Settings/SettingsViewController.swift @@ -525,14 +525,16 @@ final class SettingsViewController: UIViewController, UIScrollViewDelegate { // MARK: - Actions private func editTapped() { - let editView = ProfileEditView( + let editView = SettingsProfileEditScreen( onAddAccount: onAddAccount, - displayName: .constant(viewModel.displayName), - username: .constant(viewModel.username), + initialDisplayName: viewModel.displayName, + initialUsername: viewModel.username, publicKey: viewModel.publicKey, - displayNameError: .constant(nil), - usernameError: .constant(nil), - pendingPhoto: .constant(nil) + onExit: { [weak self] in + guard let self else { return } + self.onEditingStateChanged?(false) + self.refresh() + } ) let hosting = UIHostingController(rootView: editView) hosting.view.backgroundColor = UIColor(RosettaColors.Adaptive.background) @@ -636,6 +638,234 @@ final class SettingsViewController: UIViewController, UIScrollViewDelegate { } } +// MARK: - Settings Profile Edit Screen + +/// Stateful edit screen used by UIKit settings flow. +/// Restores toolbar (Cancel/Done) and save behavior expected by ProfileEditView. +private struct SettingsProfileEditScreen: View { + let onAddAccount: ((AuthScreen) -> Void)? + let initialDisplayName: String + let initialUsername: String + let publicKey: String + let onExit: () -> Void + + @Environment(\.dismiss) private var dismiss + + @State private var displayName: String + @State private var username: String + @State private var displayNameError: String? + @State private var usernameError: String? + @State private var pendingPhoto: UIImage? + @State private var isSaving = false + @State private var resultHandlerId: UUID? + @State private var saveTimeoutTask: Task? + @State private var didNotifyExit = false + + init( + onAddAccount: ((AuthScreen) -> Void)?, + initialDisplayName: String, + initialUsername: String, + publicKey: String, + onExit: @escaping () -> Void + ) { + self.onAddAccount = onAddAccount + self.initialDisplayName = initialDisplayName + self.initialUsername = initialUsername + self.publicKey = publicKey + self.onExit = onExit + + _displayName = State(initialValue: initialDisplayName) + _username = State(initialValue: initialUsername) + } + + var body: some View { + GeometryReader { geometry in + let safeTop = geometry.safeAreaInsets.top + + ZStack(alignment: .top) { + RosettaColors.Adaptive.background + .ignoresSafeArea() + + ScrollView(.vertical, showsIndicators: false) { + ProfileEditView( + onAddAccount: onAddAccount, + displayName: $displayName, + username: $username, + publicKey: publicKey, + displayNameError: $displayNameError, + usernameError: $usernameError, + pendingPhoto: $pendingPhoto + ) + .padding(.top, safeTop + 50) + } + + toolbar(safeTop: safeTop) + } + } + .toolbar(.hidden, for: .navigationBar) + .onDisappear { + cleanupPendingResultHandler() + notifyExitIfNeeded() + } + } + + private var hasProfileChanges: Bool { + displayName != initialDisplayName + || username != initialUsername + || pendingPhoto != nil + } + + @ViewBuilder + private func toolbar(safeTop: CGFloat) -> some View { + HStack { + Button { + if isSaving { + cleanupPendingResultHandler() + isSaving = false + } + pendingPhoto = nil + dismiss() + } label: { + Text("Cancel") + .font(.system(size: 17, weight: .medium)) + .foregroundStyle(RosettaColors.Adaptive.text) + .frame(height: 44) + .padding(.horizontal, 10) + } + .buttonStyle(.plain) + .glassCapsule() + + Spacer() + + Button { + saveProfile() + } label: { + Text("Done") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle( + hasProfileChanges + ? RosettaColors.Adaptive.text + : RosettaColors.Adaptive.text.opacity(0.4) + ) + .frame(height: 44) + .padding(.horizontal, 10) + } + .buttonStyle(.plain) + .glassCapsule() + .disabled(isSaving) + } + .padding(.horizontal, 15) + .padding(.top, safeTop) + .frame(maxWidth: .infinity) + .frame(height: safeTop + 44) + } + + private func saveProfile() { + let trimmedName = displayName.trimmingCharacters(in: .whitespaces) + let trimmedUsername = username.trimmingCharacters(in: .whitespaces).lowercased() + + if let error = ProfileValidator.validateDisplayName(trimmedName) { + displayNameError = error.errorDescription + return + } + if let error = ProfileValidator.validateUsername(trimmedUsername) { + usernameError = error.errorDescription + return + } + displayNameError = nil + usernameError = nil + + guard hasProfileChanges else { + dismiss() + return + } + + let hasTextChanges = trimmedName != initialDisplayName + || trimmedUsername != initialUsername + + if !hasTextChanges { + commitPendingAvatar() + dismiss() + return + } + + guard !isSaving else { return } + isSaving = true + + let handlerId = ProtocolManager.shared.addResultHandler { result in + Task { @MainActor in + guard isSaving else { return } + cleanupPendingResultHandler() + isSaving = false + + if result.resultCode == ResultCode.usernameTaken.rawValue { + usernameError = "This username is already taken" + } else { + updateLocalProfile(displayName: trimmedName, username: trimmedUsername) + commitPendingAvatar() + dismiss() + } + } + } + resultHandlerId = handlerId + + if let hash = SessionManager.shared.privateKeyHash { + var packet = PacketUserInfo() + packet.username = trimmedUsername + packet.title = trimmedName + packet.privateKey = hash + ProtocolManager.shared.sendPacket(packet) + } else { + // No server auth hash available — timeout fallback below will save locally. + } + + saveTimeoutTask?.cancel() + saveTimeoutTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: 10_000_000_000) + guard !Task.isCancelled else { return } + guard isSaving else { return } + + cleanupPendingResultHandler() + isSaving = false + updateLocalProfile(displayName: trimmedName, username: trimmedUsername) + commitPendingAvatar() + dismiss() + } + } + + private func cleanupPendingResultHandler() { + if let id = resultHandlerId { + ProtocolManager.shared.removeResultHandler(id) + resultHandlerId = nil + } + saveTimeoutTask?.cancel() + saveTimeoutTask = nil + } + + private func commitPendingAvatar() { + guard let photo = pendingPhoto else { return } + AvatarRepository.shared.saveAvatar(publicKey: publicKey, image: photo) + pendingPhoto = nil + } + + private func updateLocalProfile(displayName: String, username: String) { + AccountManager.shared.updateProfile( + displayName: displayName, + username: username + ) + SessionManager.shared.updateDisplayNameAndUsername( + displayName: displayName, + username: username + ) + } + + private func notifyExitIfNeeded() { + guard !didNotifyExit else { return } + didNotifyExit = true + onExit() + } +} + // MARK: - SwiftUI Edit Button (needed for @MainActor closure compatibility) private struct SettingsEditButton: View { diff --git a/Rosetta/RosettaApp.swift b/Rosetta/RosettaApp.swift index 781e2ce..26a5c49 100644 --- a/Rosetta/RosettaApp.swift +++ b/Rosetta/RosettaApp.swift @@ -818,6 +818,9 @@ struct RosettaApp: App { #if DEBUG DebugPerformanceBenchmarks.runIfRequested() #endif + + // Eager init: in-app banner manager now owns a UIKit overlay window. + _ = InAppBannerManager.shared } @AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false @@ -825,7 +828,6 @@ struct RosettaApp: App { @AppStorage("hasLaunchedBefore") private var hasLaunchedBefore = false @State private var appState: AppState? @State private var transitionOverlay: Bool = false - @StateObject private var bannerManager = InAppBannerManager.shared var body: some Scene { WindowGroup { @@ -850,9 +852,6 @@ struct RosettaApp: App { .opacity(transitionOverlay ? 1 : 0) .allowsHitTesting(transitionOverlay) .animation(.easeInOut(duration: 0.035), value: transitionOverlay) - - // Telegram parity: in-app notification banner. - inAppBannerOverlay } } // NOTE: preferredColorScheme removed — DarkModeWrapper is the single @@ -881,46 +880,6 @@ struct RosettaApp: App { } } - // MARK: - In-App Banner Overlay (Telegram parity) - - @ViewBuilder - private var inAppBannerOverlay: some View { - // Telegram parity: 8pt inset below safe area top (NotificationItemContainerNode.swift:98). - VStack(spacing: 0) { - if let banner = bannerManager.currentBanner { - let navigateToChat = { - bannerManager.dismiss() - let route = ChatRoute( - publicKey: banner.senderKey, - title: banner.senderName, - username: "", - verified: banner.verified - ) - AppDelegate.pendingChatRoute = route - AppDelegate.pendingChatRouteTimestamp = Date() - NotificationCenter.default.post( - name: .openChatFromNotification, - object: route - ) - } - InAppBannerView( - senderName: banner.senderName, - messagePreview: banner.messagePreview, - senderKey: banner.senderKey, - isGroup: banner.isGroup, - onTap: navigateToChat, - onDismiss: { bannerManager.dismiss() }, - onExpand: navigateToChat, - onDragBegan: { bannerManager.cancelAutoDismiss() } - ) - .padding(.top, 8) - .transition(.move(edge: .top)) - } - Spacer() - } - .animation(.easeInOut(duration: 0.4), value: bannerManager.currentBanner?.id) - } - @ViewBuilder private func rootView(for state: AppState) -> some View { switch state { diff --git a/RosettaTests/ChatListBottomInsetTests.swift b/RosettaTests/ChatListBottomInsetTests.swift new file mode 100644 index 0000000..b56b299 --- /dev/null +++ b/RosettaTests/ChatListBottomInsetTests.swift @@ -0,0 +1,115 @@ +import XCTest +@testable import Rosetta + +/// Tests for ChatList bottom inset bug fix. +/// Bug: Unwanted horizontal strip appears at bottom of ChatList (not in Calls/Settings). +/// Fix: keep platform-specific bottom inset (72 on iOS < 26 custom tab bar, 0 on iOS 26+). +final class ChatListBottomInsetTests: XCTestCase { + + var controller: ChatListCollectionController! + + override func setUp() { + super.setUp() + controller = ChatListCollectionController() + } + + override func tearDown() { + controller = nil + super.tearDown() + } + + private var expectedBottomInset: CGFloat { + if #available(iOS 26, *) { + return 0 + } else { + return 72 + } + } + + // MARK: - RED Phase Tests (These FAIL before fix, PASS after fix) + + /// Test 1: contentInset.bottom should match active tab implementation. + func testContentInsetBottomMatchesPlatform() { + // Force view load to trigger setupCollectionView() + _ = controller.view + + let collectionView = controller.value(forKey: "collectionView") as? UICollectionView + XCTAssertNotNil(collectionView, "Collection view should be initialized") + + // iOS 26+: native TabView handles inset automatically (0 here). + // iOS < 26: custom floating tab bar needs 72pt manual inset. + XCTAssertEqual( + collectionView?.contentInset.bottom, + expectedBottomInset, + "Bottom inset should match platform-specific tab bar behavior" + ) + } + + /// Test 2: Verify automatic safe area adjustment is enabled + func testContentInsetAdjustmentBehaviorIsAutomatic() { + _ = controller.view + + let collectionView = controller.value(forKey: "collectionView") as? UICollectionView + XCTAssertNotNil(collectionView, "Collection view should be initialized") + + XCTAssertEqual( + collectionView?.contentInsetAdjustmentBehavior, + .automatic, + "Should use automatic adjustment (respects tab bar safe area)" + ) + } + + /// Test 3: Verify no excessive bottom inset (must not exceed custom tab bar space). + func testBottomInsetIsNotExcessive() { + _ = controller.view + + let collectionView = controller.value(forKey: "collectionView") as? UICollectionView + XCTAssertNotNil(collectionView, "Collection view should be initialized") + + let maxReasonableInset: CGFloat = 72 + XCTAssertLessThanOrEqual( + collectionView?.contentInset.bottom ?? 0, + maxReasonableInset, + "Bottom inset should not exceed \(maxReasonableInset)pt" + ) + } + + // MARK: - Integration Tests + + /// Test 4: Verify controller can be instantiated without crashes + func testControllerInitialization() { + XCTAssertNotNil(controller, "Controller should initialize successfully") + _ = controller.view + XCTAssertNotNil(controller.view, "View should load without crashes") + } + + /// Test 5: Verify collection view constraints are properly set + func testCollectionViewConstraints() { + _ = controller.view + + let collectionView = controller.value(forKey: "collectionView") as? UICollectionView + XCTAssertNotNil(collectionView, "Collection view should be initialized") + + // Collection view should be edge-to-edge (no custom insets) + XCTAssertFalse( + collectionView?.translatesAutoresizingMaskIntoConstraints ?? true, + "Should use Auto Layout constraints" + ) + } + + // MARK: - Performance Tests + + /// Test 6: Verify content inset query is fast (not computed on every access) + func testContentInsetPerformance() { + _ = controller.view + + let collectionView = controller.value(forKey: "collectionView") as? UICollectionView + XCTAssertNotNil(collectionView) + + measure { + for _ in 0..<1000 { + _ = collectionView?.contentInset.bottom + } + } + } +} diff --git a/fastlane/Appfile b/fastlane/Appfile deleted file mode 100644 index 323c509..0000000 --- a/fastlane/Appfile +++ /dev/null @@ -1,4 +0,0 @@ -app_identifier("com.rosetta.dev") - -apple_id("melissa.james2000@aol.com") -# team_id и itc_team_id не нужны для индивидуального аккаунта — Fastlane определит автоматически diff --git a/fastlane/Fastfile b/fastlane/Fastfile deleted file mode 100644 index 6deb38a..0000000 --- a/fastlane/Fastfile +++ /dev/null @@ -1,104 +0,0 @@ -default_platform(:ios) - -platform :ios do - - # ─── Хелпер: читает MARKETING_VERSION из pbxproj ─── - def current_marketing_version - pbxproj = File.read("../Rosetta.xcodeproj/project.pbxproj") - pbxproj.match(/MARKETING_VERSION = ([\d.]+);/)[1] - end - - # ─── Хелпер: читает CURRENT_PROJECT_VERSION из pbxproj ─── - def current_build_number - pbxproj = File.read("../Rosetta.xcodeproj/project.pbxproj") - pbxproj.match(/CURRENT_PROJECT_VERSION = (\d+);/)[1].to_i - end - - # ─── Хелпер: инкремент patch версии (1.0.4 → 1.0.5) ─── - def bump_patch(version) - parts = version.split(".").map(&:to_i) - parts[2] = (parts[2] || 0) + 1 - parts.join(".") - end - - # ─── Хелпер: обновляет MARKETING_VERSION в pbxproj ─── - def set_marketing_version(new_version) - path = "../Rosetta.xcodeproj/project.pbxproj" - content = File.read(path) - content.gsub!(/MARKETING_VERSION = [\d.]+;/, "MARKETING_VERSION = #{new_version};") - File.write(path, content) - UI.success("MARKETING_VERSION → #{new_version}") - end - - # ─── Хелпер: обновляет CURRENT_PROJECT_VERSION в pbxproj ─── - def set_build_number(new_build) - path = "../Rosetta.xcodeproj/project.pbxproj" - content = File.read(path) - content.gsub!(/CURRENT_PROJECT_VERSION = \d+;/, "CURRENT_PROJECT_VERSION = #{new_build};") - File.write(path, content) - UI.success("CURRENT_PROJECT_VERSION → #{new_build}") - end - - # ─── Автоинкремент build number ─── - desc "Increment build number (CURRENT_PROJECT_VERSION)" - lane :bump_build do - new_build = current_build_number + 1 - set_build_number(new_build) - end - - # ─── Инкремент версии (patch) ─── - desc "Increment version number (patch)" - lane :bump_version do - new_version = bump_patch(current_marketing_version) - set_marketing_version(new_version) - end - - # ─── Билд для TestFlight ─── - desc "Build and upload to TestFlight" - lane :beta do - build_app( - project: "Rosetta.xcodeproj", - scheme: "Rosetta", - configuration: "Release", - export_method: "app-store", - clean: true, - xcargs: "SWIFT_OPTIMIZATION_LEVEL='-Onone'" - ) - - upload_to_testflight( - skip_waiting_for_build_processing: true - ) - - # Инкремент только после успешной сборки И загрузки - new_version = bump_patch(current_marketing_version) - set_marketing_version(new_version) - - new_build = current_build_number + 1 - set_build_number(new_build) - end - - # ─── Release в App Store ─── - desc "Build and upload to App Store" - lane :release do - build_app( - project: "Rosetta.xcodeproj", - scheme: "Rosetta", - configuration: "Release", - export_method: "app-store", - clean: true, - xcargs: "SWIFT_OPTIMIZATION_LEVEL='-Onone'" - ) - - upload_to_app_store( - force: true, - skip_screenshots: true - ) - - new_version = bump_patch(current_marketing_version) - set_marketing_version(new_version) - - new_build = current_build_number + 1 - set_build_number(new_build) - end - -end diff --git a/fastlane/README.md b/fastlane/README.md deleted file mode 100644 index d6fc128..0000000 --- a/fastlane/README.md +++ /dev/null @@ -1,56 +0,0 @@ -fastlane documentation ----- - -# Installation - -Make sure you have the latest version of the Xcode command line tools installed: - -```sh -xcode-select --install -``` - -For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane) - -# Available Actions - -## iOS - -### ios bump_build - -```sh -[bundle exec] fastlane ios bump_build -``` - -Increment build number (CURRENT_PROJECT_VERSION) - -### ios bump_version - -```sh -[bundle exec] fastlane ios bump_version -``` - -Increment version number (patch) - -### ios beta - -```sh -[bundle exec] fastlane ios beta -``` - -Build and upload to TestFlight - -### ios release - -```sh -[bundle exec] fastlane ios release -``` - -Build and upload to App Store - ----- - -This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run. - -More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools). - -The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools).