Баннер авторизации нового устройства в чат-листе (Telegram parity) + фикс навигации Backup
This commit is contained in:
@@ -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 */
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
buildConfiguration = "Release"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
|
||||
@@ -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 всегда видим при скролле.
|
||||
"""
|
||||
)
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?() }
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user