Баннер авторизации нового устройства в чат-листе (Telegram parity) + фикс навигации Backup

This commit is contained in:
2026-04-17 09:03:47 +05:00
parent 2adce86528
commit 6db6a24969
6 changed files with 229 additions and 25 deletions

View File

@@ -718,7 +718,7 @@
CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 38;
CURRENT_PROJECT_VERSION = 39;
DEVELOPMENT_TEAM = QN8Z263QGX;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -734,7 +734,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.3.7;
MARKETING_VERSION = 1.3.8;
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -758,7 +758,7 @@
CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 38;
CURRENT_PROJECT_VERSION = 39;
DEVELOPMENT_TEAM = QN8Z263QGX;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -774,7 +774,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.3.7;
MARKETING_VERSION = 1.3.8;
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -949,7 +949,7 @@
C19929D9466573F31997B2C0 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
defaultConfigurationName = Release;
};
853F295D2F4B50410092AD05 /* Build configuration list for PBXProject "Rosetta" */ = {
isa = XCConfigurationList;
@@ -958,7 +958,7 @@
853F296C2F4B50420092AD05 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
defaultConfigurationName = Release;
};
853F296D2F4B50420092AD05 /* Build configuration list for PBXNativeTarget "Rosetta" */ = {
isa = XCConfigurationList;
@@ -967,7 +967,7 @@
853F296F2F4B50420092AD05 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
defaultConfigurationName = Release;
};
A8D200712F9000010092AD05 /* Build configuration list for PBXNativeTarget "RosettaUITests" */ = {
isa = XCConfigurationList;
@@ -976,7 +976,7 @@
A8D200622F9000010092AD05 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
defaultConfigurationName = Release;
};
B5D2E60ADEB8AE2E8F7615C6 /* Build configuration list for PBXNativeTarget "RosettaNotificationService" */ = {
isa = XCConfigurationList;
@@ -985,7 +985,7 @@
0140D6320A9CF4B5E933E0B1 /* Debug */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
defaultConfigurationName = Release;
};
LA00000062F8D22220092AD05 /* Build configuration list for PBXNativeTarget "RosettaLiveActivityWidget" */ = {
isa = XCConfigurationList;
@@ -994,7 +994,7 @@
LA00000082F8D22220092AD05 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */

View File

@@ -66,7 +66,7 @@
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
buildConfiguration = "Release"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"

View File

@@ -12,23 +12,20 @@ enum ReleaseNotes {
version: appVersion,
body: """
**Выделение и пересылка сообщений**
Долгое нажатие на сообщение для выделения нескольких. Массовая пересылка, удаление и отправка все типы: текст, фото, голосовые, файлы, приглашения в группы.
**Пересылка сообщений**
Preview bar в composer при пересылке. Навигация в целевой чат. Поддержка всех типов: текст, фото, голосовые, файлы, инвайты.
**Голосовые сообщения**
Запись, предпросмотр, lock-to-record, кросс-платформенная совместимость Desktop/Android (WebM/Opus ↔ M4A). Waveform, воспроизведение, blob-анимация.
**Прогресс загрузки фото**
Telegram-style progress ring при отправке фото и файлов. Реальный прогресс CDN upload до 100%.
**Создание групп**
Новый флоу: поиск контактов, мульти-выбор, glass UI, фото и описание группы. Можно создать группу без участников.
**Групповые аватарки**
Отправка и шифрование аватарок в группах. Desktop parity.
**UIKit миграция**
Chat Detail, Chat List header, Appearance, Settings — полный перевод на UIKit для Telegram-level производительности.
**Индикация прочтения**
Telegram-exact галочки в чат-листе и баблах. Корректный unread badge после синхронизации.
**Tab Bar**
Редизайн 1:1 Telegram — dual-layer маскировка, Lottie-иконки, плавные анимации и badge.
**Пуш-уведомления**
Аватарки в системных пушах. In-app баннер Telegram parity. Групповые пуши, Desktop-suppression, 65+ тестов.
**Плавные анимации**
Вставка сообщений с spring-анимацией. Skeleton без мерцания при открытии кешированных чатов. Date pill всегда видим при скролле.
"""
)
]

View File

@@ -113,6 +113,10 @@ final class ChatListRootViewController: UIViewController, UINavigationController
private var openChatObserver: NSObjectProtocol?
private var didBecomeActiveObserver: NSObjectProtocol?
// Device alert banner (shown when new device tries to login)
private var deviceAlertBanner: DeviceAlertBannerView?
private var lastPendingDeviceId: String?
// PERF: Throttle render to prevent rapid-fire UI updates (blue flash bug).
private var pendingRenderWork: DispatchWorkItem?
private var lastRenderTime: CFAbsoluteTime = 0
@@ -468,6 +472,7 @@ final class ChatListRootViewController: UIViewController, UINavigationController
_ = DialogRepository.shared.dialogs
_ = SessionManager.shared.syncBatchInProgress
_ = ProtocolManager.shared.connectionState
_ = ProtocolManager.shared.pendingDeviceVerification
_ = AvatarRepository.shared.avatarVersion
} onChange: { [weak self] in
Task { @MainActor [weak self] in
@@ -503,9 +508,88 @@ final class ChatListRootViewController: UIViewController, UINavigationController
private func render() {
updateNavigationTitle()
updateDeviceAlertBanner()
renderList()
}
private func updateDeviceAlertBanner() {
let pending = ProtocolManager.shared.pendingDeviceVerification
let newId = pending?.deviceId
// No change skip
guard newId != lastPendingDeviceId else { return }
lastPendingDeviceId = newId
if let device = pending {
showDeviceAlertBanner(device: device)
} else {
hideDeviceAlertBanner()
}
}
private func showDeviceAlertBanner(device: DeviceEntry) {
guard deviceAlertBanner == nil else {
deviceAlertBanner?.configure(deviceName: device.deviceName, deviceOs: device.deviceOs)
return
}
let banner = DeviceAlertBannerView()
banner.configure(deviceName: device.deviceName, deviceOs: device.deviceOs)
banner.onAccept = { [weak self] in
ProtocolManager.shared.acceptDevice(device.deviceId)
self?.hideDeviceAlertBanner()
}
banner.onDecline = { [weak self] in
ProtocolManager.shared.declineDevice(device.deviceId)
self?.hideDeviceAlertBanner()
}
let inset: CGFloat = 10
let bannerY = headerTotalHeight + searchChromeHeight
banner.frame = CGRect(
x: inset,
y: bannerY,
width: view.bounds.width - inset * 2,
height: DeviceAlertBannerView.bannerHeight
)
banner.alpha = 0
banner.transform = CGAffineTransform(translationX: 0, y: -20)
view.insertSubview(banner, belowSubview: searchHeaderView)
deviceAlertBanner = banner
// Push list content down via the collection view inside listController
let extraInset = DeviceAlertBannerView.bannerHeight + inset
if let cv = listController.view.subviews.first(where: { $0 is UICollectionView }) as? UICollectionView {
cv.contentInset.top += extraInset
}
UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseOut) {
banner.alpha = 1
banner.transform = .identity
}
}
private func hideDeviceAlertBanner() {
guard let banner = deviceAlertBanner else { return }
deviceAlertBanner = nil
lastPendingDeviceId = nil
let inset: CGFloat = 10
let extraInset = DeviceAlertBannerView.bannerHeight + inset
UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseIn, animations: {
banner.alpha = 0
banner.transform = CGAffineTransform(translationX: 0, y: -20)
}) { _ in
banner.removeFromSuperview()
}
// Restore list inset
if let cv = listController.view.subviews.first(where: { $0 is UICollectionView }) as? UICollectionView {
cv.contentInset.top = max(0, cv.contentInset.top - extraInset)
}
}
/// Telegram-style toolbar fade: 0.14s linear out, 0.3s linear in
private func animateToolbarForSearch(active: Bool) {
let targetAlpha: CGFloat = active ? 0.0 : 1.0

View File

@@ -0,0 +1,113 @@
import UIKit
/// Telegram-style banner shown at top of chat list when a new device login is detected.
/// Accept/Decline buttons let the user authorize or reject the device.
final class DeviceAlertBannerView: UIView {
var onAccept: (() -> Void)?
var onDecline: (() -> Void)?
private let glass = TelegramGlassUIView()
private let shieldIcon = UIImageView()
private let titleLabel = UILabel()
private let subtitleLabel = UILabel()
private let acceptButton = UIButton(type: .system)
private let declineButton = UIButton(type: .system)
static let bannerHeight: CGFloat = 80
override init(frame: CGRect) {
super.init(frame: frame)
setupSubviews()
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
private func setupSubviews() {
// Glass background
glass.fixedCornerRadius = 14
glass.clipsToBounds = true
addSubview(glass)
// Shield icon
let config = UIImage.SymbolConfiguration(pointSize: 24, weight: .medium)
shieldIcon.image = UIImage(systemName: "shield.lefthalf.filled", withConfiguration: config)
shieldIcon.tintColor = UIColor(red: 0.14, green: 0.54, blue: 0.9, alpha: 1) // primaryBlue
shieldIcon.contentMode = .center
addSubview(shieldIcon)
// Title
titleLabel.text = "New device login"
titleLabel.font = .systemFont(ofSize: 15, weight: .semibold)
titleLabel.textColor = UIColor { $0.userInterfaceStyle == .dark ? .white : .black }
addSubview(titleLabel)
// Subtitle (device name)
subtitleLabel.font = .systemFont(ofSize: 13, weight: .regular)
subtitleLabel.textColor = UIColor { $0.userInterfaceStyle == .dark
? UIColor.white.withAlphaComponent(0.6)
: UIColor.black.withAlphaComponent(0.5)
}
subtitleLabel.lineBreakMode = .byTruncatingTail
addSubview(subtitleLabel)
// Accept button
acceptButton.setTitle("Accept", for: .normal)
acceptButton.titleLabel?.font = .systemFont(ofSize: 14, weight: .semibold)
acceptButton.setTitleColor(.white, for: .normal)
acceptButton.backgroundColor = UIColor(red: 0.14, green: 0.54, blue: 0.9, alpha: 1)
acceptButton.layer.cornerRadius = 14
acceptButton.addTarget(self, action: #selector(acceptTapped), for: .touchUpInside)
addSubview(acceptButton)
// Decline button
declineButton.setTitle("Decline", for: .normal)
declineButton.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium)
declineButton.setTitleColor(UIColor(red: 0.9, green: 0.25, blue: 0.25, alpha: 1), for: .normal)
declineButton.addTarget(self, action: #selector(declineTapped), for: .touchUpInside)
addSubview(declineButton)
}
func configure(deviceName: String, deviceOs: String) {
subtitleLabel.text = "\(deviceName) · \(deviceOs)"
}
override func layoutSubviews() {
super.layoutSubviews()
glass.frame = bounds
let leftPad: CGFloat = 14
let iconSize: CGFloat = 36
shieldIcon.frame = CGRect(
x: leftPad,
y: (bounds.height - iconSize) / 2,
width: iconSize,
height: iconSize
)
let textX = shieldIcon.frame.maxX + 10
let buttonW: CGFloat = 70
let declineW: CGFloat = 65
let rightPad: CGFloat = 12
let buttonsW = buttonW + 8 + declineW + rightPad
let textW = bounds.width - textX - buttonsW
titleLabel.frame = CGRect(x: textX, y: 18, width: textW, height: 20)
subtitleLabel.frame = CGRect(x: textX, y: 40, width: textW, height: 18)
let btnY: CGFloat = (bounds.height - 28) / 2
acceptButton.frame = CGRect(
x: bounds.width - rightPad - declineW - 8 - buttonW,
y: btnY, width: buttonW, height: 28
)
declineButton.frame = CGRect(
x: bounds.width - rightPad - declineW,
y: btnY, width: declineW, height: 28
)
}
@objc private func acceptTapped() { onAccept?() }
@objc private func declineTapped() { onDecline?() }
}

View File

@@ -9,6 +9,7 @@ struct SafetyView: View {
@State private var copiedPublicKey = false
@State private var copiedPrivateKey = false
@State private var showDeleteConfirmation = false
@State private var navController: UINavigationController?
private var publicKey: String {
SessionManager.shared.currentPublicKey
@@ -38,6 +39,11 @@ struct SafetyView: View {
.padding(.leading, 8)
.padding(.top, 4)
}
.background(
NavigationControllerAccessor { nav in
self.navController = nav
}
)
.background(RosettaColors.Adaptive.background)
.scrollContentBackground(.hidden)
.navigationBarTitleDisplayMode(.inline)
@@ -129,7 +135,11 @@ struct SafetyView: View {
VStack(alignment: .leading, spacing: 8) {
SettingsCard {
VStack(spacing: 0) {
NavigationLink(value: SettingsDestination.backup) {
Button {
let hosting = UIHostingController(rootView: BackupView())
hosting.view.backgroundColor = UIColor(RosettaColors.Adaptive.background)
navController?.pushViewController(hosting, animated: true)
} label: {
HStack {
Text("Backup")
.font(.system(size: 15))