Баннер Telegram-паритета и прямой переход в чат по тапу
This commit is contained in:
@@ -12,12 +12,12 @@
|
|||||||
<key>RosettaLiveActivityWidget.xcscheme_^#shared#^_</key>
|
<key>RosettaLiveActivityWidget.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
<integer>3</integer>
|
<integer>1</integer>
|
||||||
</dict>
|
</dict>
|
||||||
<key>RosettaNotificationService.xcscheme_^#shared#^_</key>
|
<key>RosettaNotificationService.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
<integer>1</integer>
|
<integer>3</integer>
|
||||||
</dict>
|
</dict>
|
||||||
<key>RosettaUITests.xcscheme_^#shared#^_</key>
|
<key>RosettaUITests.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
|
|||||||
@@ -166,6 +166,7 @@ final class DialogRepository {
|
|||||||
case .avatar: lastMessageText = "Avatar"
|
case .avatar: lastMessageText = "Avatar"
|
||||||
case .messages: lastMessageText = "Forwarded message"
|
case .messages: lastMessageText = "Forwarded message"
|
||||||
case .call: lastMessageText = "Call"
|
case .call: lastMessageText = "Call"
|
||||||
|
case .voice: lastMessageText = "Voice message"
|
||||||
@unknown default: lastMessageText = "Attachment"
|
@unknown default: lastMessageText = "Attachment"
|
||||||
}
|
}
|
||||||
} else if textIsEmpty {
|
} else if textIsEmpty {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import Combine
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
@@ -9,13 +8,14 @@ import UIKit
|
|||||||
/// Queue: one banner at a time. New banner replaces current.
|
/// Queue: one banner at a time. New banner replaces current.
|
||||||
/// Auto-dismiss: 5 seconds. Swipe-up or tap dismisses immediately.
|
/// Auto-dismiss: 5 seconds. Swipe-up or tap dismisses immediately.
|
||||||
@MainActor
|
@MainActor
|
||||||
final class InAppBannerManager: ObservableObject {
|
final class InAppBannerManager {
|
||||||
|
|
||||||
static let shared = InAppBannerManager()
|
static let shared = InAppBannerManager()
|
||||||
|
|
||||||
@Published var currentBanner: BannerData?
|
|
||||||
|
|
||||||
private var dismissTask: Task<Void, Never>?
|
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
|
/// Notification posted by SessionManager.processIncomingMessage
|
||||||
/// when a foreground message should trigger an in-app banner.
|
/// when a foreground message should trigger an in-app banner.
|
||||||
@@ -37,25 +37,126 @@ final class InAppBannerManager: ObservableObject {
|
|||||||
func show(_ data: BannerData) {
|
func show(_ data: BannerData) {
|
||||||
// Replace current banner.
|
// Replace current banner.
|
||||||
dismissTask?.cancel()
|
dismissTask?.cancel()
|
||||||
currentBanner = data
|
dismissTask = nil
|
||||||
|
dismissGeneration &+= 1
|
||||||
|
guard let controller = ensureOverlayController() else { return }
|
||||||
|
|
||||||
// Auto-dismiss after 5 seconds (Telegram parity).
|
controller.presentBanner(
|
||||||
dismissTask = Task {
|
data: data,
|
||||||
try? await Task.sleep(for: .seconds(5))
|
onTap: { [weak self] in
|
||||||
guard !Task.isCancelled else { return }
|
self?.openChat(with: data)
|
||||||
dismiss()
|
},
|
||||||
|
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()
|
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).
|
/// Cancel auto-dismiss timer (e.g., during active pan gesture).
|
||||||
/// Telegram: cancels timeout when abs(translation) > 4pt.
|
/// Telegram: cancels timeout when abs(translation) > 4pt.
|
||||||
func cancelAutoDismiss() {
|
func cancelAutoDismiss() {
|
||||||
dismissTask?.cancel()
|
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
|
// MARK: - Data
|
||||||
@@ -70,3 +171,109 @@ final class InAppBannerManager: ObservableObject {
|
|||||||
var id: String { senderKey }
|
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 senderName = dialog?.opponentTitle ?? ""
|
||||||
let preview: String = {
|
let preview: String = {
|
||||||
if !text.isEmpty { return text }
|
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"
|
return "New message"
|
||||||
}()
|
}()
|
||||||
let bannerData = InAppBannerManager.BannerData(
|
let bannerData = InAppBannerManager.BannerData(
|
||||||
|
|||||||
@@ -1,213 +1,453 @@
|
|||||||
import SwiftUI
|
|
||||||
import UIKit
|
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
|
/// Telegram-style in-app notification banner implemented fully in UIKit.
|
||||||
/// while the app is in foreground and the user is NOT in that chat.
|
/// No SwiftUI bridge is used in this view.
|
||||||
///
|
final class InAppBannerView: UIView {
|
||||||
/// 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
|
|
||||||
|
|
||||||
@Environment(\.colorScheme) private var colorScheme
|
private enum Metrics {
|
||||||
|
// Telegram in-app notifications currently use compact layout.
|
||||||
@State private var dragOffset: CGFloat = 0
|
static let panelHeight: CGFloat = 64
|
||||||
@State private var hapticPrepared = false
|
static let cornerRadius: CGFloat = 24
|
||||||
@State private var hasExpandedHaptic = false
|
static let avatarSize: CGFloat = 40
|
||||||
@State private var dragCancelledTimeout = false
|
static let avatarLeading: CGFloat = 12
|
||||||
|
// Telegram compact reference: leftInset = imageSize + imageSpacing = 40 + 22 = 62.
|
||||||
private let panelHeight: CGFloat = 74
|
static let textLeading: CGFloat = 62
|
||||||
private let cornerRadius: CGFloat = 24
|
static let trailingInset: CGFloat = 10
|
||||||
private let avatarSize: CGFloat = 54
|
static let verticalTextSpacing: CGFloat = 1
|
||||||
private let horizontalMargin: CGFloat = 8
|
static let topInset: CGFloat = 6
|
||||||
|
|
||||||
private var textColor: Color {
|
|
||||||
colorScheme == .dark ? .white : .black
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
static let preferredHeight: CGFloat = Metrics.panelHeight
|
||||||
HStack(spacing: 23) {
|
static let preferredTopInset: CGFloat = Metrics.topInset
|
||||||
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)
|
|
||||||
}
|
|
||||||
Spacer(minLength: 0)
|
|
||||||
}
|
|
||||||
.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).
|
private let glassView = TelegramGlassUIView(frame: .zero)
|
||||||
if abs(translation) > 1 && !hapticPrepared {
|
|
||||||
UIImpactFeedbackGenerator(style: .medium).prepare()
|
private let avatarBackgroundView = UIView()
|
||||||
hapticPrepared = true
|
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)
|
||||||
|
}
|
||||||
|
hapticFeedback?.impactOccurred()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cancel auto-dismiss timer (Telegram: abs > 4pt).
|
override var intrinsicContentSize: CGSize {
|
||||||
if abs(translation) > 4 && !dragCancelledTimeout {
|
CGSize(width: UIView.noIntrinsicMetric, height: Metrics.panelHeight)
|
||||||
dragCancelledTimeout = true
|
|
||||||
onDragBegan()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Haptic on expand threshold crossing (Telegram: bounds.minY < -24pt).
|
override init(frame: CGRect) {
|
||||||
if translation > 24 && !hasExpandedHaptic {
|
super.init(frame: frame)
|
||||||
hasExpandedHaptic = true
|
setupView()
|
||||||
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if translation < 0 {
|
required init?(coder: NSCoder) {
|
||||||
// Dragging up — full 1:1 tracking.
|
fatalError("init(coder:) has not been implemented")
|
||||||
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.
|
func applyCallbacks(
|
||||||
hapticPrepared = false
|
onTap: @escaping () -> Void,
|
||||||
hasExpandedHaptic = false
|
onDismiss: @escaping () -> Void,
|
||||||
dragCancelledTimeout = false
|
onExpand: @escaping () -> Void,
|
||||||
|
onDragBegan: @escaping () -> Void,
|
||||||
// Expand: pulled down > 20pt or fast downward velocity (Telegram: -20pt / 300pt/s).
|
onDragEndedWithoutAction: @escaping () -> Void
|
||||||
if value.translation.height > 20 || velocity > 300 {
|
) {
|
||||||
withAnimation(.easeInOut(duration: 0.3)) {
|
self.onTap = onTap
|
||||||
dragOffset = 0
|
self.onDismiss = onDismiss
|
||||||
|
self.onExpand = onExpand
|
||||||
|
self.onDragBegan = onDragBegan
|
||||||
|
self.onDragEndedWithoutAction = onDragEndedWithoutAction
|
||||||
}
|
}
|
||||||
onExpand()
|
|
||||||
|
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 {
|
||||||
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dismiss: swiped up > 5pt or fast upward velocity (Telegram: 5pt / 200pt/s).
|
groupIconView.isHidden = true
|
||||||
if value.translation.height < -5 || velocity < -200 {
|
|
||||||
withAnimation(.easeOut(duration: 0.4)) {
|
if let image = AvatarRepository.shared.loadAvatar(publicKey: senderKey) {
|
||||||
dragOffset = -200
|
avatarImageView.image = image
|
||||||
}
|
avatarImageView.isHidden = false
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
|
avatarBackgroundView.isHidden = true
|
||||||
onDismiss()
|
avatarInitialsLabel.isHidden = true
|
||||||
}
|
avatarTintOverlayView.isHidden = true
|
||||||
} else {
|
} else {
|
||||||
// Spring back (Telegram: 0.3s easeInOut).
|
avatarImageView.isHidden = true
|
||||||
withAnimation(.easeInOut(duration: 0.3)) {
|
avatarBackgroundView.isHidden = false
|
||||||
dragOffset = 0
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
)
|
|
||||||
.onTapGesture {
|
|
||||||
onTap()
|
|
||||||
}
|
|
||||||
.contentShape(Rectangle())
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
// MARK: - Gestures
|
||||||
private var avatarView: some View {
|
|
||||||
let colorIndex = Self.avatarColorIndex(for: senderName, publicKey: senderKey)
|
|
||||||
let pair = Self.avatarColors[colorIndex % Self.avatarColors.count]
|
|
||||||
|
|
||||||
if isGroup {
|
@objc
|
||||||
// Group: solid tint circle + person.2.fill (ChatRowView parity).
|
private func handleTapGesture(_ recognizer: UITapGestureRecognizer) {
|
||||||
ZStack {
|
guard recognizer.state == .ended, !isPanning else { return }
|
||||||
Circle().fill(Color(hex: UInt(pair.tint)))
|
emit(onTap)
|
||||||
Image(systemName: "person.2.fill")
|
|
||||||
.font(.system(size: 20, weight: .medium))
|
|
||||||
.foregroundStyle(.white.opacity(0.9))
|
|
||||||
}
|
}
|
||||||
.frame(width: avatarSize, height: avatarSize)
|
|
||||||
|
@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)
|
||||||
|
|
||||||
|
if abs(translation.y) > 1.0 {
|
||||||
|
if hapticFeedback == nil {
|
||||||
|
hapticFeedback = UIImpactFeedbackGenerator(style: .medium)
|
||||||
|
}
|
||||||
|
hapticFeedback?.prepare()
|
||||||
|
}
|
||||||
|
|
||||||
|
setInteractiveOffset(displayedOffset)
|
||||||
|
|
||||||
|
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 {
|
} else {
|
||||||
// Personal: Mantine "light" variant (AvatarView parity).
|
if cancelledTimeout {
|
||||||
let avatarImage = AvatarRepository.shared.loadAvatar(publicKey: senderKey)
|
cancelledTimeout = false
|
||||||
if let image = avatarImage {
|
emit(onDragEndedWithoutAction)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
animateOffsetToOrigin(completion: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Color helpers (duplicated from Colors.swift — NSE can't import main target)
|
willBeExpanded = false
|
||||||
|
hapticFeedback = nil
|
||||||
|
|
||||||
private static let avatarColors: [(tint: UInt32, text: UInt32)] = [
|
case .cancelled, .failed:
|
||||||
(0x228be6, 0x74c0fc), (0x15aabf, 0x66d9e8), (0xbe4bdb, 0xe599f7),
|
isPanning = false
|
||||||
(0x40c057, 0x8ce99a), (0x4c6ef5, 0x91a7ff), (0x82c91e, 0xc0eb75),
|
if cancelledTimeout {
|
||||||
(0xfd7e14, 0xffc078), (0xe64980, 0xfaa2c1), (0xfa5252, 0xffa8a8),
|
cancelledTimeout = false
|
||||||
(0x12b886, 0x63e6be), (0x7950f2, 0xb197fc),
|
emit(onDragEndedWithoutAction)
|
||||||
]
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
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:
|
default:
|
||||||
let first = words[0].first.map(String.init) ?? ""
|
break
|
||||||
let second = words[1].first.map(String.init) ?? ""
|
}
|
||||||
return (first + second).uppercased()
|
}
|
||||||
|
|
||||||
|
// 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 {
|
if !draft.isEmpty {
|
||||||
messageText = draft
|
messageText = draft
|
||||||
}
|
}
|
||||||
// Suppress notifications & clear badge immediately (no 600ms delay).
|
// Non-mutating: only touches MessageRepository (Set), safe during animation.
|
||||||
// setDialogActive only touches MessageRepository.activeDialogs (Set),
|
|
||||||
// does NOT mutate DialogRepository, so ForEach won't rebuild.
|
|
||||||
MessageRepository.shared.setDialogActive(route.publicKey, isActive: true)
|
MessageRepository.shared.setDialogActive(route.publicKey, isActive: true)
|
||||||
SessionManager.shared.resetIdleTimer()
|
SessionManager.shared.resetIdleTimer()
|
||||||
updateReadEligibility()
|
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.
|
// Delay DialogRepository mutations to let navigation transition complete.
|
||||||
// Without this, DialogRepository update rebuilds ChatListView's ForEach
|
// Without this, DialogRepository update rebuilds ChatListView's ForEach
|
||||||
// mid-navigation, recreating the NavigationLink and canceling the push.
|
// 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 }
|
guard isViewActive else { return }
|
||||||
activateDialog()
|
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()
|
updateReadEligibility()
|
||||||
markDialogAsRead()
|
markDialogAsRead()
|
||||||
// Desktop parity: skip online subscription and user info fetch for system accounts
|
// 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.
|
// Force view load so dataSource/collectionView are initialized.
|
||||||
controller.loadViewIfNeeded()
|
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
|
// Apply initial composer state
|
||||||
if useUIKitComposer {
|
if useUIKitComposer {
|
||||||
|
|||||||
@@ -96,6 +96,9 @@ struct ChatListView: View {
|
|||||||
isDetailPresented = presented
|
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) {
|
.navigationDestination(isPresented: $showRequestChats) {
|
||||||
RequestChatsView(
|
RequestChatsView(
|
||||||
@@ -145,17 +148,30 @@ struct ChatListView: View {
|
|||||||
guard let route = notification.object as? ChatRoute else { return }
|
guard let route = notification.object as? ChatRoute else { return }
|
||||||
AppDelegate.pendingChatRoute = nil
|
AppDelegate.pendingChatRoute = nil
|
||||||
AppDelegate.pendingChatRouteTimestamp = nil
|
AppDelegate.pendingChatRouteTimestamp = nil
|
||||||
// If already inside a chat, pop first then push after animation.
|
|
||||||
// Direct path replacement reuses the same ChatDetailView (SwiftUI optimization),
|
// Already showing this chat.
|
||||||
// which only updates the toolbar but keeps the old messages.
|
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 {
|
if !navigationState.path.isEmpty {
|
||||||
navigationState.path = []
|
navigationState.path.append(route)
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If Requests screen is open, close it first, then open chat.
|
||||||
|
if showRequestChats {
|
||||||
|
showRequestChats = false
|
||||||
|
DispatchQueue.main.async {
|
||||||
navigationState.path = [route]
|
navigationState.path = [route]
|
||||||
}
|
}
|
||||||
} else {
|
return
|
||||||
navigationState.path = [route]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Root chat-list state: open target chat directly.
|
||||||
|
navigationState.path = [route]
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
// Cold start fallback: ChatListView didn't exist when notification was posted.
|
// Cold start fallback: ChatListView didn't exist when notification was posted.
|
||||||
|
|||||||
@@ -136,9 +136,12 @@ final class RequestChatsController: UIViewController {
|
|||||||
collectionView.translatesAutoresizingMaskIntoConstraints = false
|
collectionView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
collectionView.backgroundColor = .clear
|
collectionView.backgroundColor = .clear
|
||||||
collectionView.delegate = self
|
collectionView.delegate = self
|
||||||
|
collectionView.showsHorizontalScrollIndicator = false
|
||||||
collectionView.showsVerticalScrollIndicator = false
|
collectionView.showsVerticalScrollIndicator = false
|
||||||
|
collectionView.alwaysBounceHorizontal = false
|
||||||
collectionView.alwaysBounceVertical = true
|
collectionView.alwaysBounceVertical = true
|
||||||
collectionView.contentInset.bottom = 80
|
collectionView.contentInset.bottom = 0
|
||||||
|
collectionView.verticalScrollIndicatorInsets.bottom = 0
|
||||||
view.addSubview(collectionView)
|
view.addSubview(collectionView)
|
||||||
|
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ final class ChatListCell: UICollectionViewCell {
|
|||||||
private var isPinned = false
|
private var isPinned = false
|
||||||
private var wasBadgeVisible = false
|
private var wasBadgeVisible = false
|
||||||
private var wasMentionBadgeVisible = false
|
private var wasMentionBadgeVisible = false
|
||||||
|
private var isSystemChat = false
|
||||||
|
|
||||||
// MARK: - Init
|
// MARK: - Init
|
||||||
|
|
||||||
@@ -354,8 +355,10 @@ final class ChatListCell: UICollectionViewCell {
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
authorLabel.frame = .zero
|
authorLabel.frame = .zero
|
||||||
|
// System chats (Updates/Safe): keep preview visually farther from title.
|
||||||
|
let messageY: CGFloat = isSystemChat ? 29 : 21
|
||||||
messageLabel.frame = CGRect(
|
messageLabel.frame = CGRect(
|
||||||
x: textLeft, y: 21,
|
x: textLeft, y: messageY,
|
||||||
width: max(0, messageMaxW), height: 38
|
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) {
|
func configure(with dialog: Dialog, isSyncing: Bool, typingUsers: Set<String>? = nil) {
|
||||||
let isDark = traitCollection.userInterfaceStyle == .dark
|
let isDark = traitCollection.userInterfaceStyle == .dark
|
||||||
isPinned = dialog.isPinned
|
isPinned = dialog.isPinned
|
||||||
|
isSystemChat = isSystemDialog(dialog)
|
||||||
|
|
||||||
// Colors
|
// Colors
|
||||||
let titleColor = isDark ? UIColor.white : UIColor.black
|
let titleColor = isDark ? UIColor.white : UIColor.black
|
||||||
@@ -696,6 +700,15 @@ final class ChatListCell: UICollectionViewCell {
|
|||||||
messageLabel.textColor = secondaryColor
|
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
|
// MARK: - Typing Indicator
|
||||||
|
|
||||||
private func configureTypingIndicator(dialog: Dialog, typingUsers: Set<String>, color: UIColor) {
|
private func configureTypingIndicator(dialog: Dialog, typingUsers: Set<String>, color: UIColor) {
|
||||||
@@ -859,6 +872,7 @@ final class ChatListCell: UICollectionViewCell {
|
|||||||
wasMentionBadgeVisible = false
|
wasMentionBadgeVisible = false
|
||||||
badgeContainer.transform = .identity
|
badgeContainer.transform = .identity
|
||||||
mentionImageView.transform = .identity
|
mentionImageView.transform = .identity
|
||||||
|
isSystemChat = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Highlight
|
// MARK: - Highlight
|
||||||
|
|||||||
@@ -45,6 +45,16 @@ final class ChatListCollectionController: UIViewController {
|
|||||||
private var dataSource: UICollectionViewDiffableDataSource<Section, String>!
|
private var dataSource: UICollectionViewDiffableDataSource<Section, String>!
|
||||||
private var cellRegistration: UICollectionView.CellRegistration<ChatListCell, Dialog>!
|
private var cellRegistration: UICollectionView.CellRegistration<ChatListCell, Dialog>!
|
||||||
private var requestsCellRegistration: UICollectionView.CellRegistration<ChatListRequestsCell, Int>!
|
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
|
// Dialog lookup by ID for cell configuration
|
||||||
private var dialogMap: [String: Dialog] = [:]
|
private var dialogMap: [String: Dialog] = [:]
|
||||||
@@ -71,10 +81,11 @@ final class ChatListCollectionController: UIViewController {
|
|||||||
collectionView.prefetchDataSource = self
|
collectionView.prefetchDataSource = self
|
||||||
collectionView.keyboardDismissMode = .onDrag
|
collectionView.keyboardDismissMode = .onDrag
|
||||||
collectionView.showsVerticalScrollIndicator = false
|
collectionView.showsVerticalScrollIndicator = false
|
||||||
|
collectionView.showsHorizontalScrollIndicator = false
|
||||||
collectionView.alwaysBounceVertical = true
|
collectionView.alwaysBounceVertical = true
|
||||||
|
collectionView.alwaysBounceHorizontal = false
|
||||||
collectionView.contentInsetAdjustmentBehavior = .automatic
|
collectionView.contentInsetAdjustmentBehavior = .automatic
|
||||||
// Bottom inset so last cells aren't hidden behind tab bar
|
applyBottomInsets()
|
||||||
collectionView.contentInset.bottom = 80
|
|
||||||
view.addSubview(collectionView)
|
view.addSubview(collectionView)
|
||||||
|
|
||||||
NSLayoutConstraint.activate([
|
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 {
|
private func createLayout() -> UICollectionViewCompositionalLayout {
|
||||||
var listConfig = UICollectionLayoutListConfiguration(appearance: .plain)
|
var listConfig = UICollectionLayoutListConfiguration(appearance: .plain)
|
||||||
listConfig.showsSeparators = false
|
listConfig.showsSeparators = false
|
||||||
|
|||||||
@@ -525,14 +525,16 @@ final class SettingsViewController: UIViewController, UIScrollViewDelegate {
|
|||||||
// MARK: - Actions
|
// MARK: - Actions
|
||||||
|
|
||||||
private func editTapped() {
|
private func editTapped() {
|
||||||
let editView = ProfileEditView(
|
let editView = SettingsProfileEditScreen(
|
||||||
onAddAccount: onAddAccount,
|
onAddAccount: onAddAccount,
|
||||||
displayName: .constant(viewModel.displayName),
|
initialDisplayName: viewModel.displayName,
|
||||||
username: .constant(viewModel.username),
|
initialUsername: viewModel.username,
|
||||||
publicKey: viewModel.publicKey,
|
publicKey: viewModel.publicKey,
|
||||||
displayNameError: .constant(nil),
|
onExit: { [weak self] in
|
||||||
usernameError: .constant(nil),
|
guard let self else { return }
|
||||||
pendingPhoto: .constant(nil)
|
self.onEditingStateChanged?(false)
|
||||||
|
self.refresh()
|
||||||
|
}
|
||||||
)
|
)
|
||||||
let hosting = UIHostingController(rootView: editView)
|
let hosting = UIHostingController(rootView: editView)
|
||||||
hosting.view.backgroundColor = UIColor(RosettaColors.Adaptive.background)
|
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)
|
// MARK: - SwiftUI Edit Button (needed for @MainActor closure compatibility)
|
||||||
|
|
||||||
private struct SettingsEditButton: View {
|
private struct SettingsEditButton: View {
|
||||||
|
|||||||
@@ -818,6 +818,9 @@ struct RosettaApp: App {
|
|||||||
#if DEBUG
|
#if DEBUG
|
||||||
DebugPerformanceBenchmarks.runIfRequested()
|
DebugPerformanceBenchmarks.runIfRequested()
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
// Eager init: in-app banner manager now owns a UIKit overlay window.
|
||||||
|
_ = InAppBannerManager.shared
|
||||||
}
|
}
|
||||||
|
|
||||||
@AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false
|
@AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false
|
||||||
@@ -825,7 +828,6 @@ struct RosettaApp: App {
|
|||||||
@AppStorage("hasLaunchedBefore") private var hasLaunchedBefore = false
|
@AppStorage("hasLaunchedBefore") private var hasLaunchedBefore = false
|
||||||
@State private var appState: AppState?
|
@State private var appState: AppState?
|
||||||
@State private var transitionOverlay: Bool = false
|
@State private var transitionOverlay: Bool = false
|
||||||
@StateObject private var bannerManager = InAppBannerManager.shared
|
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
@@ -850,9 +852,6 @@ struct RosettaApp: App {
|
|||||||
.opacity(transitionOverlay ? 1 : 0)
|
.opacity(transitionOverlay ? 1 : 0)
|
||||||
.allowsHitTesting(transitionOverlay)
|
.allowsHitTesting(transitionOverlay)
|
||||||
.animation(.easeInOut(duration: 0.035), value: transitionOverlay)
|
.animation(.easeInOut(duration: 0.035), value: transitionOverlay)
|
||||||
|
|
||||||
// Telegram parity: in-app notification banner.
|
|
||||||
inAppBannerOverlay
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// NOTE: preferredColorScheme removed — DarkModeWrapper is the single
|
// 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
|
@ViewBuilder
|
||||||
private func rootView(for state: AppState) -> some View {
|
private func rootView(for state: AppState) -> some View {
|
||||||
switch state {
|
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