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).