Баннер Telegram-паритета и прямой переход в чат по тапу

This commit is contained in:
2026-04-13 23:34:54 +05:00
parent 05420337cc
commit 69ac9cd270
17 changed files with 1104 additions and 444 deletions

View File

@@ -12,12 +12,12 @@
<key>RosettaLiveActivityWidget.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>3</integer>
<integer>1</integer>
</dict>
<key>RosettaNotificationService.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>1</integer>
<integer>3</integer>
</dict>
<key>RosettaUITests.xcscheme_^#shared#^_</key>
<dict>

View File

@@ -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 {

View File

@@ -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<Void, Never>?
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)
}
}

View File

@@ -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(

View File

@@ -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)
}
}
}

View File

@@ -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

View File

@@ -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 {

View File

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

View File

@@ -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([

View File

@@ -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<String>? = 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<String>, color: UIColor) {
@@ -859,6 +872,7 @@ final class ChatListCell: UICollectionViewCell {
wasMentionBadgeVisible = false
badgeContainer.transform = .identity
mentionImageView.transform = .identity
isSystemChat = false
}
// MARK: - Highlight

View File

@@ -45,6 +45,16 @@ final class ChatListCollectionController: UIViewController {
private var dataSource: UICollectionViewDiffableDataSource<Section, String>!
private var cellRegistration: UICollectionView.CellRegistration<ChatListCell, Dialog>!
private var requestsCellRegistration: UICollectionView.CellRegistration<ChatListRequestsCell, Int>!
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

View File

@@ -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<Void, Never>?
@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 {

View File

@@ -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 {

View File

@@ -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
}
}
}
}

View File

@@ -1,4 +0,0 @@
app_identifier("com.rosetta.dev")
apple_id("melissa.james2000@aol.com")
# team_id и itc_team_id не нужны для индивидуального аккаунта — Fastlane определит автоматически

View File

@@ -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

View File

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