Баннер Telegram-паритета и прямой переход в чат по тапу
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
115
RosettaTests/ChatListBottomInsetTests.swift
Normal file
115
RosettaTests/ChatListBottomInsetTests.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
app_identifier("com.rosetta.dev")
|
||||
|
||||
apple_id("melissa.james2000@aol.com")
|
||||
# team_id и itc_team_id не нужны для индивидуального аккаунта — Fastlane определит автоматически
|
||||
@@ -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
|
||||
@@ -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).
|
||||
Reference in New Issue
Block a user