Уведомления CarPlay, панель вложений с Lottie, фикс reply preview, плавная анимация клавиатуры, стабильность WebSocket
This commit is contained in:
@@ -323,7 +323,9 @@ final class ProtocolManager: @unchecked Sendable {
|
|||||||
|
|
||||||
sendPacketDirect(handshake)
|
sendPacketDirect(handshake)
|
||||||
|
|
||||||
// Timeout
|
// Timeout — force reconnect instead of permanent disconnect.
|
||||||
|
// `client.disconnect()` sets `isManuallyClosed = true` which kills all
|
||||||
|
// future reconnection attempts. Use `forceReconnect()` to retry.
|
||||||
handshakeTimeoutTask?.cancel()
|
handshakeTimeoutTask?.cancel()
|
||||||
handshakeTimeoutTask = Task { [weak self] in
|
handshakeTimeoutTask = Task { [weak self] in
|
||||||
do {
|
do {
|
||||||
@@ -333,8 +335,13 @@ final class ProtocolManager: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
guard let self, !Task.isCancelled else { return }
|
guard let self, !Task.isCancelled else { return }
|
||||||
if !self.handshakeComplete {
|
if !self.handshakeComplete {
|
||||||
Self.logger.error("Handshake timeout")
|
Self.logger.error("Handshake timeout — forcing reconnect")
|
||||||
self.client.disconnect()
|
self.handshakeComplete = false
|
||||||
|
self.heartbeatTask?.cancel()
|
||||||
|
Task { @MainActor in
|
||||||
|
self.connectionState = .connecting
|
||||||
|
}
|
||||||
|
self.client.forceReconnect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,6 +126,8 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
|
|||||||
}
|
}
|
||||||
task.send(.data(data)) { [weak self] error in
|
task.send(.data(data)) { [weak self] error in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
|
// Ignore errors from old sockets after forceReconnect.
|
||||||
|
guard task === self.webSocketTask else { return }
|
||||||
if let error {
|
if let error {
|
||||||
Self.logger.error("Send error: \(error.localizedDescription)")
|
Self.logger.error("Send error: \(error.localizedDescription)")
|
||||||
onFailure?(error)
|
onFailure?(error)
|
||||||
@@ -138,9 +140,12 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
|
|||||||
func sendText(_ text: String) {
|
func sendText(_ text: String) {
|
||||||
guard let task = webSocketTask else { return }
|
guard let task = webSocketTask else { return }
|
||||||
task.send(.string(text)) { [weak self] error in
|
task.send(.string(text)) { [weak self] error in
|
||||||
|
guard let self else { return }
|
||||||
|
// Ignore errors from old sockets after forceReconnect.
|
||||||
|
guard task === self.webSocketTask else { return }
|
||||||
if let error {
|
if let error {
|
||||||
Self.logger.error("Send text error: \(error.localizedDescription)")
|
Self.logger.error("Send text error: \(error.localizedDescription)")
|
||||||
self?.handleDisconnect(error: error)
|
self.handleDisconnect(error: error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -162,6 +167,11 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
|
|||||||
|
|
||||||
nonisolated func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?) {
|
nonisolated func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?) {
|
||||||
Self.logger.info("WebSocket didOpen")
|
Self.logger.info("WebSocket didOpen")
|
||||||
|
// Ignore callbacks from old (cancelled) sockets after forceReconnect.
|
||||||
|
guard webSocketTask === self.webSocketTask else {
|
||||||
|
Self.logger.info("didOpen ignored: stale socket (not current task)")
|
||||||
|
return
|
||||||
|
}
|
||||||
guard !isManuallyClosed else { return }
|
guard !isManuallyClosed else { return }
|
||||||
// Android parity: reset isConnecting on successful open (Protocol.kt onOpen).
|
// Android parity: reset isConnecting on successful open (Protocol.kt onOpen).
|
||||||
isConnecting = false
|
isConnecting = false
|
||||||
@@ -177,6 +187,11 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
|
|||||||
|
|
||||||
nonisolated func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
|
nonisolated func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
|
||||||
Self.logger.info("WebSocket didClose: \(closeCode.rawValue)")
|
Self.logger.info("WebSocket didClose: \(closeCode.rawValue)")
|
||||||
|
// Ignore callbacks from old (cancelled) sockets after forceReconnect.
|
||||||
|
guard webSocketTask === self.webSocketTask else {
|
||||||
|
Self.logger.info("didClose ignored: stale socket (not current task)")
|
||||||
|
return
|
||||||
|
}
|
||||||
isConnecting = false
|
isConnecting = false
|
||||||
isConnected = false
|
isConnected = false
|
||||||
handleDisconnect(error: nil)
|
handleDisconnect(error: nil)
|
||||||
@@ -189,6 +204,8 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
|
|||||||
|
|
||||||
task.receive { [weak self] result in
|
task.receive { [weak self] result in
|
||||||
guard let self, !isManuallyClosed else { return }
|
guard let self, !isManuallyClosed else { return }
|
||||||
|
// Ignore callbacks from old (cancelled) sockets after forceReconnect.
|
||||||
|
guard task === self.webSocketTask else { return }
|
||||||
|
|
||||||
switch result {
|
switch result {
|
||||||
case .success(let message):
|
case .success(let message):
|
||||||
|
|||||||
@@ -285,16 +285,15 @@ final class SessionManager {
|
|||||||
password: attachmentPassword
|
password: attachmentPassword
|
||||||
)
|
)
|
||||||
|
|
||||||
// Upload encrypted blob to transport server (desktop: uploadFile)
|
// Cache avatar locally BEFORE upload so outgoing avatar shows instantly
|
||||||
let tag = try await TransportManager.shared.uploadFile(
|
// (same pattern as sendMessageWithAttachments — AttachmentCache.saveImage before upload).
|
||||||
id: attachmentId,
|
|
||||||
content: Data(encryptedBlob.utf8)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Desktop parity: preview = "tag::blurhash" (same as IMAGE attachments)
|
|
||||||
let avatarImage = AvatarRepository.shared.loadAvatar(publicKey: currentPublicKey)
|
let avatarImage = AvatarRepository.shared.loadAvatar(publicKey: currentPublicKey)
|
||||||
|
if let avatarImage {
|
||||||
|
AttachmentCache.shared.saveImage(avatarImage, forAttachmentId: attachmentId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlurHash for preview (computed before upload so optimistic UI has it)
|
||||||
let blurhash = avatarImage?.blurHash(numberOfComponents: (4, 3)) ?? ""
|
let blurhash = avatarImage?.blurHash(numberOfComponents: (4, 3)) ?? ""
|
||||||
let preview = "\(tag)::\(blurhash)"
|
|
||||||
|
|
||||||
// Build aesChachaKey with Latin-1 payload (desktop sync parity)
|
// Build aesChachaKey with Latin-1 payload (desktop sync parity)
|
||||||
let aesChachaPayload = Data(latin1ForSync.utf8)
|
let aesChachaPayload = Data(latin1ForSync.utf8)
|
||||||
@@ -303,7 +302,7 @@ final class SessionManager {
|
|||||||
password: privKey
|
password: privKey
|
||||||
)
|
)
|
||||||
|
|
||||||
// Build packet with avatar attachment
|
// Build packet with avatar attachment — preview will be updated with tag after upload
|
||||||
var packet = PacketMessage()
|
var packet = PacketMessage()
|
||||||
packet.fromPublicKey = currentPublicKey
|
packet.fromPublicKey = currentPublicKey
|
||||||
packet.toPublicKey = toPublicKey
|
packet.toPublicKey = toPublicKey
|
||||||
@@ -316,8 +315,8 @@ final class SessionManager {
|
|||||||
packet.attachments = [
|
packet.attachments = [
|
||||||
MessageAttachment(
|
MessageAttachment(
|
||||||
id: attachmentId,
|
id: attachmentId,
|
||||||
preview: preview,
|
preview: blurhash, // Will be updated with "tag::blurhash" after upload
|
||||||
blob: "", // Desktop parity: blob cleared after upload
|
blob: "",
|
||||||
type: .avatar
|
type: .avatar
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
@@ -333,21 +332,40 @@ final class SessionManager {
|
|||||||
myPublicKey: currentPublicKey
|
myPublicKey: currentPublicKey
|
||||||
)
|
)
|
||||||
|
|
||||||
// Optimistic UI
|
// Optimistic UI — show message IMMEDIATELY (before upload)
|
||||||
let isConnected = ProtocolManager.shared.connectionState == .authenticated
|
|
||||||
let offlineAsSend = !isConnected
|
|
||||||
MessageRepository.shared.upsertFromMessagePacket(
|
MessageRepository.shared.upsertFromMessagePacket(
|
||||||
packet, myPublicKey: currentPublicKey, decryptedText: " ",
|
packet, myPublicKey: currentPublicKey, decryptedText: " ",
|
||||||
attachmentPassword: "rawkey:" + encrypted.plainKeyAndNonce.hexString,
|
attachmentPassword: "rawkey:" + encrypted.plainKeyAndNonce.hexString,
|
||||||
fromSync: offlineAsSend
|
fromSync: false
|
||||||
)
|
)
|
||||||
DialogRepository.shared.updateDialogFromMessages(opponentKey: packet.toPublicKey)
|
DialogRepository.shared.updateDialogFromMessages(opponentKey: packet.toPublicKey)
|
||||||
|
|
||||||
if offlineAsSend {
|
// Upload encrypted blob to transport server in background (desktop: uploadFile)
|
||||||
|
let tag: String
|
||||||
|
do {
|
||||||
|
tag = try await TransportManager.shared.uploadFile(
|
||||||
|
id: attachmentId,
|
||||||
|
content: Data(encryptedBlob.utf8)
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
// Upload failed — mark as error
|
||||||
MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .error)
|
MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .error)
|
||||||
DialogRepository.shared.updateDeliveryStatus(messageId: messageId, opponentKey: toPublicKey, status: .error)
|
DialogRepository.shared.updateDeliveryStatus(messageId: messageId, opponentKey: toPublicKey, status: .error)
|
||||||
|
Self.logger.error("📤 Avatar upload failed: \(error.localizedDescription)")
|
||||||
|
throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update preview with CDN tag (tag::blurhash)
|
||||||
|
let preview = "\(tag)::\(blurhash)"
|
||||||
|
packet.attachments = [
|
||||||
|
MessageAttachment(
|
||||||
|
id: attachmentId,
|
||||||
|
preview: preview,
|
||||||
|
blob: "", // Desktop parity: blob cleared after upload
|
||||||
|
type: .avatar
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
// Saved Messages — mark delivered locally but STILL send to server
|
// Saved Messages — mark delivered locally but STILL send to server
|
||||||
// for cross-device avatar sync. Other devices receive via sync and
|
// for cross-device avatar sync. Other devices receive via sync and
|
||||||
// update their local avatar cache.
|
// update their local avatar cache.
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ final class PerformanceLogger {
|
|||||||
|
|
||||||
/// Enable for performance testing. Disabled by default — zero overhead in production.
|
/// Enable for performance testing. Disabled by default — zero overhead in production.
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
var isEnabled = false // Set to `true` to enable perf logging
|
var isEnabled = true // Set to `true` to enable perf logging
|
||||||
#else
|
#else
|
||||||
var isEnabled = false
|
var isEnabled = false
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -11,26 +11,26 @@ enum ReleaseNotes {
|
|||||||
Entry(
|
Entry(
|
||||||
version: appVersion,
|
version: appVersion,
|
||||||
body: """
|
body: """
|
||||||
**Доставка сообщений**
|
|
||||||
Сообщения больше не теряются при потере сети. Автоматический повтор отправки при восстановлении соединения. Отправленные фото отображаются мгновенно.
|
|
||||||
|
|
||||||
**Уведомления**
|
**Уведомления**
|
||||||
Вибрация и бейдж работают когда приложение закрыто. Счётчик непрочитанных обновляется в фоне.
|
Поддержка CarPlay и фильтров Focus. Переход в чат по нажатию на уведомление из любого состояния приложения. Автопереключение на вкладку Chats.
|
||||||
|
|
||||||
**Фото и файлы**
|
**Сеть**
|
||||||
Фото скачиваются только по тапу. Пересланные фото загружаются из кэша. Файлы открываются по тапу. Свайп между фото в полноэкранном просмотре. Плавное закрытие свайпом вниз.
|
Автоматический реконнект при таймауте handshake. Защита от дублирования при переподключении.
|
||||||
|
|
||||||
|
**Клавиатура**
|
||||||
|
Плавная анимация подъёма и опускания инпута. Устранено дёрганье при закрытии клавиатуры.
|
||||||
|
|
||||||
|
**Вложения**
|
||||||
|
Новый экран отправки аватарки с анимацией. Обновлённый экран выбора файлов. Анимированный индикатор вкладок.
|
||||||
|
|
||||||
|
**Просмотр фото**
|
||||||
|
Жесты зума и навигации работают по всему экрану. Исправлен double-tap для сброса зума.
|
||||||
|
|
||||||
**Аватарки**
|
**Аватарки**
|
||||||
Превью аватарки отображается как размытое изображение до скачивания. Плавная анимация при загрузке. Скачивание по тапу.
|
Мгновенное отображение до загрузки на сервер. Корректное отображение отправленных аватарок.
|
||||||
|
|
||||||
**Шифрование**
|
|
||||||
Улучшена совместимость шифрования фото и файлов между устройствами. Пересланные фото корректно расшифровываются получателем.
|
|
||||||
|
|
||||||
**Производительность**
|
|
||||||
Оптимизация FPS клавиатуры и скролла в длинных переписках. Снижен нагрев устройства.
|
|
||||||
|
|
||||||
**Исправления**
|
**Исправления**
|
||||||
Исправлена отправка фото и аватаров на Desktop. Исправлено шифрование пересланных сообщений. Исправлен бейдж непрочитанных в tab bar. Исправлен счётчик после синхронизации. Исправлены кнопки на iOS 26+. Группировка баблов для фото-сообщений. Saved Messages: иконка закладки.
|
Отображение типа вложения (Photo, Avatar, File) при ответе на сообщение. Стабильность WebSocket-соединения.
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|||||||
124
Rosetta/DesignSystem/Components/KeyboardSyncedContainer.swift
Normal file
124
Rosetta/DesignSystem/Components/KeyboardSyncedContainer.swift
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// Wraps SwiftUI content in a UIKit container whose vertical position is animated
|
||||||
|
/// using the keyboard's exact Core Animation curve. This achieves Telegram-level
|
||||||
|
/// keyboard sync because both the keyboard and this container are animated by the
|
||||||
|
/// render server in the same Core Animation transaction — zero relative movement.
|
||||||
|
///
|
||||||
|
/// On iOS 26+, SwiftUI handles keyboard natively — this wrapper is a no-op passthrough.
|
||||||
|
struct KeyboardSyncedContainer<Content: View>: View {
|
||||||
|
let content: Content
|
||||||
|
|
||||||
|
init(@ViewBuilder content: () -> Content) {
|
||||||
|
self.content = content()
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if #available(iOS 26, *) {
|
||||||
|
content
|
||||||
|
} else {
|
||||||
|
_KeyboardSyncedRepresentable(content: content)
|
||||||
|
.ignoresSafeArea(.keyboard)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - UIViewControllerRepresentable bridge
|
||||||
|
|
||||||
|
private struct _KeyboardSyncedRepresentable<Content: View>: UIViewControllerRepresentable {
|
||||||
|
let content: Content
|
||||||
|
|
||||||
|
func makeUIViewController(context: Context) -> _KeyboardSyncedVC<Content> {
|
||||||
|
_KeyboardSyncedVC(rootView: content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIViewController(_ vc: _KeyboardSyncedVC<Content>, context: Context) {
|
||||||
|
vc.hostingController.rootView = content
|
||||||
|
}
|
||||||
|
|
||||||
|
func sizeThatFits(
|
||||||
|
_ proposal: ProposedViewSize,
|
||||||
|
uiViewController vc: _KeyboardSyncedVC<Content>,
|
||||||
|
context: Context
|
||||||
|
) -> CGSize? {
|
||||||
|
let width = proposal.width ?? UIScreen.main.bounds.width
|
||||||
|
let fittingSize = vc.hostingController.sizeThatFits(
|
||||||
|
in: CGSize(width: width, height: UIView.layoutFittingCompressedSize.height)
|
||||||
|
)
|
||||||
|
return CGSize(width: width, height: fittingSize.height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - UIKit view controller that animates with the keyboard
|
||||||
|
|
||||||
|
final class _KeyboardSyncedVC<Content: View>: UIViewController {
|
||||||
|
|
||||||
|
let hostingController: UIHostingController<Content>
|
||||||
|
private var bottomInset: CGFloat = 34
|
||||||
|
|
||||||
|
init(rootView: Content) {
|
||||||
|
hostingController = UIHostingController(rootView: rootView)
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError() }
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
view.backgroundColor = .clear
|
||||||
|
|
||||||
|
addChild(hostingController)
|
||||||
|
hostingController.view.backgroundColor = .clear
|
||||||
|
view.addSubview(hostingController.view)
|
||||||
|
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
|
hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
|
hostingController.view.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
|
hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||||
|
])
|
||||||
|
hostingController.didMove(toParent: self)
|
||||||
|
|
||||||
|
// Read safe area bottom inset
|
||||||
|
if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||||
|
let window = scene.keyWindow ?? scene.windows.first {
|
||||||
|
let bottom = window.safeAreaInsets.bottom
|
||||||
|
bottomInset = bottom < 50 ? bottom : 34
|
||||||
|
}
|
||||||
|
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(keyboardWillChangeFrame),
|
||||||
|
name: UIResponder.keyboardWillChangeFrameNotification,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func keyboardWillChangeFrame(_ notification: Notification) {
|
||||||
|
guard let info = notification.userInfo,
|
||||||
|
let endFrame = info[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect,
|
||||||
|
let duration = info[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval,
|
||||||
|
let curveRaw = info[UIResponder.keyboardAnimationCurveUserInfoKey] as? Int
|
||||||
|
else { return }
|
||||||
|
|
||||||
|
let screenHeight = UIScreen.main.bounds.height
|
||||||
|
let keyboardTop = endFrame.origin.y
|
||||||
|
let isVisible = keyboardTop < screenHeight
|
||||||
|
let endHeight = isVisible ? (screenHeight - keyboardTop) : 0
|
||||||
|
let padding = isVisible ? max(0, endHeight - bottomInset) : 0
|
||||||
|
|
||||||
|
// Animate with the KEYBOARD'S EXACT curve in the SAME Core Animation transaction.
|
||||||
|
// The render server interpolates both the keyboard position and our transform
|
||||||
|
// together for each frame — pixel-perfect sync, zero gap variation.
|
||||||
|
let options = UIView.AnimationOptions(rawValue: UInt(curveRaw) << 16)
|
||||||
|
UIView.animate(withDuration: duration, delay: 0, options: [options, .beginFromCurrentState]) {
|
||||||
|
self.view.transform = CGAffineTransform(translationX: 0, y: -padding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
NotificationCenter.default.removeObserver(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,14 +40,11 @@ final class KeyboardTracker: ObservableObject {
|
|||||||
private var animTickCount = 0
|
private var animTickCount = 0
|
||||||
private var animationNumber = 0
|
private var animationNumber = 0
|
||||||
private var lastTickTime: CFTimeInterval = 0
|
private var lastTickTime: CFTimeInterval = 0
|
||||||
|
/// Monotonic guard — sync view can give non-monotonic eased values
|
||||||
|
/// in the first 2 ticks while Core Animation commits the animation.
|
||||||
|
private var lastEased: CGFloat = 0
|
||||||
|
|
||||||
// Presentation-layer sync: a hidden UIView animated with the keyboard's
|
// Cubic bezier control points — deterministic keyboard curve approximation.
|
||||||
// exact curve. Reading its presentation layer on each CADisplayLink tick
|
|
||||||
// gives us the real easing value — no guessing control points.
|
|
||||||
private var syncView: UIView?
|
|
||||||
private var usingSyncAnimation = false
|
|
||||||
|
|
||||||
// Cubic bezier fallback (used when syncView is unavailable)
|
|
||||||
private var bezierP1x: CGFloat = 0.25
|
private var bezierP1x: CGFloat = 0.25
|
||||||
private var bezierP1y: CGFloat = 0.1
|
private var bezierP1y: CGFloat = 0.1
|
||||||
private var bezierP2x: CGFloat = 0.25
|
private var bezierP2x: CGFloat = 0.25
|
||||||
@@ -86,6 +83,12 @@ final class KeyboardTracker: ObservableObject {
|
|||||||
if #available(iOS 26, *) { return }
|
if #available(iOS 26, *) { return }
|
||||||
PerformanceLogger.shared.track("keyboard.kvo")
|
PerformanceLogger.shared.track("keyboard.kvo")
|
||||||
guard !isAnimating else { return }
|
guard !isAnimating else { return }
|
||||||
|
#if DEBUG
|
||||||
|
let rawPad = max(0, keyboardHeight - bottomInset)
|
||||||
|
if abs(rawPad - keyboardPadding) > 4 {
|
||||||
|
print("⌨️ 👆 KVO | height=\(Int(keyboardHeight)) → pad=\(Int(rawPad)) | current=\(Int(keyboardPadding))")
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
if keyboardHeight <= 0 {
|
if keyboardHeight <= 0 {
|
||||||
// Flush any pending KVO value and stop coalescing
|
// Flush any pending KVO value and stop coalescing
|
||||||
@@ -183,9 +186,19 @@ final class KeyboardTracker: ObservableObject {
|
|||||||
lastNotificationPadding = targetPadding
|
lastNotificationPadding = targetPadding
|
||||||
|
|
||||||
PerformanceLogger.shared.track("keyboard.notification")
|
PerformanceLogger.shared.track("keyboard.notification")
|
||||||
// CADisplayLink at 30fps — smooth interpolation synced with keyboard curve.
|
#if DEBUG
|
||||||
// BubbleContextMenuOverlay.updateUIView is SKIPPED during animation
|
let direction = targetPadding > keyboardPadding ? "⬆️ SHOW" : "⬇️ HIDE"
|
||||||
// (isAnimatingKeyboard flag) — eliminates 40+ UIKit bridge operations per tick.
|
print("⌨️ \(direction) | current=\(Int(keyboardPadding)) → target=\(Int(targetPadding)) | delta=\(Int(delta)) | duration=\(String(format: "%.3f", duration))s | curve=\(curveRaw)")
|
||||||
|
#endif
|
||||||
|
// Filter spurious notifications (delta=0, duration=0) — keyboard frame
|
||||||
|
// didn't actually change. Happens on some iOS versions during text input.
|
||||||
|
guard abs(delta) > 1 || targetPadding != keyboardPadding else { return }
|
||||||
|
|
||||||
|
// Animate with sync view (keyboard's exact CA curve) — no pre-apply.
|
||||||
|
// Pre-apply caused a visible "jump" because input moved 16pt instantly
|
||||||
|
// before the keyboard had started moving. Without pre-apply, there may
|
||||||
|
// be ~1 frame of slight overlap (keyboard ahead of input) but it's
|
||||||
|
// imperceptible at 60Hz (16ms) and provides a much smoother feel.
|
||||||
if abs(delta) > 1, targetPadding != keyboardPadding {
|
if abs(delta) > 1, targetPadding != keyboardPadding {
|
||||||
isAnimatingKeyboard = true
|
isAnimatingKeyboard = true
|
||||||
startPaddingAnimation(to: targetPadding, duration: duration, curveRaw: curveRaw)
|
startPaddingAnimation(to: targetPadding, duration: duration, curveRaw: curveRaw)
|
||||||
@@ -238,31 +251,32 @@ final class KeyboardTracker: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - CADisplayLink animation (legacy, kept for reference)
|
// MARK: - CADisplayLink animation (sync view + bezier fallback)
|
||||||
|
|
||||||
|
// Sync view: hidden UIView animated with keyboard's exact curve.
|
||||||
|
// Reading its presentation layer gives the real easing value — no guessing.
|
||||||
|
private var syncView: UIView?
|
||||||
|
|
||||||
private func startPaddingAnimation(to target: CGFloat, duration: CFTimeInterval, curveRaw: Int) {
|
private func startPaddingAnimation(to target: CGFloat, duration: CFTimeInterval, curveRaw: Int) {
|
||||||
animationNumber += 1
|
animationNumber += 1
|
||||||
animStartPadding = keyboardPadding
|
animStartPadding = keyboardPadding
|
||||||
animTargetPadding = target
|
animTargetPadding = target
|
||||||
animStartTime = 0
|
animStartTime = CACurrentMediaTime()
|
||||||
animDuration = max(duration, 0.05)
|
animDuration = max(duration, 0.05)
|
||||||
animTickCount = 0
|
animTickCount = 0
|
||||||
|
lastEased = 0
|
||||||
|
|
||||||
// Primary: sync with the keyboard's exact curve via presentation layer.
|
// Primary: sync view matches keyboard's exact curve (same CA transaction).
|
||||||
// UIView.animate called HERE lands in the same Core Animation transaction
|
// No lead, no pre-apply — the natural ~1 frame SwiftUI processing delay
|
||||||
// as the keyboard notification — identical timing function and start time.
|
// creates the "keyboard pushes input" effect (like Telegram).
|
||||||
usingSyncAnimation = setupSyncAnimation(duration: duration, curveRaw: curveRaw)
|
let syncOK = setupSyncAnimation(duration: duration, curveRaw: curveRaw)
|
||||||
|
|
||||||
// Fallback: cubic bezier approximation (CSS "ease").
|
// Fallback: cubic bezier (only if sync view can't be created).
|
||||||
if !usingSyncAnimation {
|
if !syncOK {
|
||||||
configureBezier(curveRaw: curveRaw)
|
configureBezier(curveRaw: curveRaw)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reuse existing display link to preserve vsync phase alignment.
|
// Reuse existing display link to preserve vsync phase alignment.
|
||||||
// Creating a new CADisplayLink on each animation resets the phase,
|
|
||||||
// causing alternating frame intervals (15/18ms instead of steady 16.6ms).
|
|
||||||
// No FPS cap — runs at device native rate (120Hz ProMotion, 60Hz standard).
|
|
||||||
// BubbleContextMenuOverlay.updateUIView is a no-op, so per-tick cost is trivial.
|
|
||||||
if let proxy = displayLinkProxy {
|
if let proxy = displayLinkProxy {
|
||||||
proxy.isPaused = false
|
proxy.isPaused = false
|
||||||
} else {
|
} else {
|
||||||
@@ -272,18 +286,13 @@ final class KeyboardTracker: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Starts a hidden UIView animation matching the keyboard's exact curve.
|
/// Creates a hidden UIView animated with the keyboard's exact curve.
|
||||||
/// Returns true if sync animation was set up successfully.
|
|
||||||
private func setupSyncAnimation(duration: CFTimeInterval, curveRaw: Int) -> Bool {
|
private func setupSyncAnimation(duration: CFTimeInterval, curveRaw: Int) -> Bool {
|
||||||
guard let window = UIApplication.shared.connectedScenes
|
guard let window = UIApplication.shared.connectedScenes
|
||||||
.compactMap({ $0 as? UIWindowScene }).first?.keyWindow else {
|
.compactMap({ $0 as? UIWindowScene }).first?.keyWindow else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove previous sync view completely — a fresh view
|
|
||||||
// guarantees clean presentation layer with no animation history.
|
|
||||||
// Reusing the same view causes Core Animation to coalesce/skip
|
|
||||||
// repeated 0→1 opacity animations on subsequent calls.
|
|
||||||
syncView?.layer.removeAllAnimations()
|
syncView?.layer.removeAllAnimations()
|
||||||
syncView?.removeFromSuperview()
|
syncView?.removeFromSuperview()
|
||||||
|
|
||||||
@@ -292,15 +301,8 @@ final class KeyboardTracker: ObservableObject {
|
|||||||
window.addSubview(view)
|
window.addSubview(view)
|
||||||
syncView = view
|
syncView = view
|
||||||
|
|
||||||
// UIView.AnimationOptions encodes the curve in bits 16-19.
|
|
||||||
// For rawValue 7 (private keyboard curve), this passes through to
|
|
||||||
// Core Animation with Apple's exact timing function.
|
|
||||||
let options = UIView.AnimationOptions(rawValue: UInt(curveRaw) << 16)
|
let options = UIView.AnimationOptions(rawValue: UInt(curveRaw) << 16)
|
||||||
UIView.animate(
|
UIView.animate(withDuration: duration, delay: 0, options: [options]) {
|
||||||
withDuration: duration,
|
|
||||||
delay: 0,
|
|
||||||
options: [options]
|
|
||||||
) {
|
|
||||||
view.alpha = 1
|
view.alpha = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,33 +310,42 @@ final class KeyboardTracker: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func animationTick() {
|
private func animationTick() {
|
||||||
let tickStart = CACurrentMediaTime()
|
|
||||||
PerformanceLogger.shared.track("keyboard.animTick")
|
PerformanceLogger.shared.track("keyboard.animTick")
|
||||||
animTickCount += 1
|
animTickCount += 1
|
||||||
|
|
||||||
let now = CACurrentMediaTime()
|
let now = CACurrentMediaTime()
|
||||||
lastTickTime = now
|
lastTickTime = now
|
||||||
|
|
||||||
// Get eased fraction — either from presentation layer (exact) or bezier (fallback).
|
let elapsed = now - animStartTime
|
||||||
let eased: CGFloat
|
let timeComplete = elapsed >= animDuration
|
||||||
var isComplete = false
|
|
||||||
|
|
||||||
if usingSyncAnimation, let presentation = syncView?.layer.presentation() {
|
// Read eased fraction: sync view (exact) or bezier (fallback).
|
||||||
let fraction = CGFloat(presentation.opacity)
|
var eased: CGFloat
|
||||||
eased = fraction
|
if let presentation = syncView?.layer.presentation() {
|
||||||
isComplete = fraction >= 0.999
|
eased = min(max(CGFloat(presentation.opacity), 0), 1)
|
||||||
} else {
|
} else {
|
||||||
// Bezier fallback
|
|
||||||
if animStartTime == 0 { animStartTime = now }
|
|
||||||
let elapsed = now - animStartTime
|
|
||||||
let t = min(elapsed / animDuration, 1.0)
|
let t = min(elapsed / animDuration, 1.0)
|
||||||
eased = cubicBezierEase(t)
|
eased = cubicBezierEase(t)
|
||||||
isComplete = t >= 1.0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Guard: presentation layer can return NaN opacity during edge cases
|
// Enforce monotonic progress — sync view's presentation layer can give
|
||||||
// (window transition, sync view removed). NaN propagating to keyboardPadding
|
// non-monotonic values in the first 2 ticks while Core Animation commits
|
||||||
// causes `Color.clear.frame(height: NaN)` → CoreGraphics NaN errors → FPS freeze.
|
// the animation to the render server. Without this guard, padding oscillates
|
||||||
|
// (e.g. 312 → 306 → 310 → 302) causing a visible "jerk".
|
||||||
|
if eased < lastEased {
|
||||||
|
eased = lastEased
|
||||||
|
}
|
||||||
|
lastEased = eased
|
||||||
|
|
||||||
|
// SHOW only: lead by ~1 frame to prevent keyboard overlapping input.
|
||||||
|
// The value we publish NOW is rendered by SwiftUI NEXT frame.
|
||||||
|
// Without lead, rendered input position is 1 frame behind keyboard → overlap.
|
||||||
|
// 0.065 ≈ 1 frame at 60Hz (16.6ms / 250ms). Continuous, not a jump.
|
||||||
|
// HIDE doesn't need lead — natural 1-frame delay creates acceptable gap.
|
||||||
|
if animTargetPadding > animStartPadding {
|
||||||
|
eased = min(eased + 0.065, 1.0)
|
||||||
|
}
|
||||||
|
|
||||||
guard eased.isFinite else {
|
guard eased.isFinite else {
|
||||||
displayLinkProxy?.isPaused = true
|
displayLinkProxy?.isPaused = true
|
||||||
lastTickTime = 0
|
lastTickTime = 0
|
||||||
@@ -342,27 +353,32 @@ final class KeyboardTracker: ObservableObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Round to nearest 1pt — now that BubbleContextMenuOverlay.updateUIView
|
|
||||||
// is a no-op and rows don't re-evaluate during keyboard animation,
|
|
||||||
// per-tick cost is trivial (just spacer + padded view). 1pt = maximum smoothness.
|
|
||||||
let raw = animStartPadding + (animTargetPadding - animStartPadding) * eased
|
let raw = animStartPadding + (animTargetPadding - animStartPadding) * eased
|
||||||
let rounded = max(0, round(raw))
|
let rounded = max(0, round(raw))
|
||||||
|
|
||||||
if isComplete || animTickCount > 30 {
|
if timeComplete || animTickCount > 30 {
|
||||||
|
let prevPadding = keyboardPadding
|
||||||
keyboardPadding = max(0, animTargetPadding)
|
keyboardPadding = max(0, animTargetPadding)
|
||||||
// Pause instead of invalidate — preserves vsync phase for next animation.
|
// Pause instead of invalidate — preserves vsync phase for next animation.
|
||||||
displayLinkProxy?.isPaused = true
|
displayLinkProxy?.isPaused = true
|
||||||
lastTickTime = 0
|
lastTickTime = 0
|
||||||
isAnimatingKeyboard = false
|
isAnimatingKeyboard = false
|
||||||
|
#if DEBUG
|
||||||
|
let elapsedMs = elapsed * 1000
|
||||||
|
print("⌨️ ✅ DONE | ticks=\(animTickCount) | final=\(Int(animTargetPadding)) | lastDelta=\(Int(animTargetPadding - prevPadding))pt | elapsed=\(String(format: "%.0f", elapsedMs))ms")
|
||||||
|
#endif
|
||||||
} else if rounded != keyboardPadding {
|
} else if rounded != keyboardPadding {
|
||||||
|
#if DEBUG
|
||||||
|
let prevPad = keyboardPadding
|
||||||
|
#endif
|
||||||
keyboardPadding = rounded
|
keyboardPadding = rounded
|
||||||
|
#if DEBUG
|
||||||
|
if animTickCount <= 5 {
|
||||||
|
let delta = Int(rounded - prevPad)
|
||||||
|
print("⌨️ TICK #\(animTickCount) | eased=\(String(format: "%.3f", eased)) | pad=\(Int(rounded)) | delta=\(delta)pt | elapsed=\(String(format: "%.1f", elapsed * 1000))ms")
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
#if DEBUG
|
|
||||||
let tickMs = (CACurrentMediaTime() - tickStart) * 1000
|
|
||||||
if tickMs > 16 {
|
|
||||||
PerformanceLogger.shared.track("keyboard.slowTick")
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Cubic bezier fallback
|
// MARK: - Cubic bezier fallback
|
||||||
@@ -380,7 +396,8 @@ final class KeyboardTracker: ObservableObject {
|
|||||||
bezierP1x = 0; bezierP1y = 0
|
bezierP1x = 0; bezierP1y = 0
|
||||||
bezierP2x = 1.0; bezierP2y = 1.0
|
bezierP2x = 1.0; bezierP2y = 1.0
|
||||||
default:
|
default:
|
||||||
// CSS "ease" — closest known approximation of curve 7.
|
// CSS "ease" — approximation of iOS keyboard curve 7.
|
||||||
|
// Only used as fallback when sync view is unavailable.
|
||||||
bezierP1x = 0.25; bezierP1y = 0.1
|
bezierP1x = 0.25; bezierP1y = 0.1
|
||||||
bezierP2x = 0.25; bezierP2y = 1.0
|
bezierP2x = 0.25; bezierP2y = 1.0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ struct CallsView: View {
|
|||||||
Text("Edit")
|
Text("Edit")
|
||||||
.font(.system(size: 17, weight: .medium))
|
.font(.system(size: 17, weight: .medium))
|
||||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||||
|
.fixedSize(horizontal: true, vertical: false)
|
||||||
.frame(height: 44)
|
.frame(height: 44)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Photos
|
import Photos
|
||||||
import PhotosUI
|
import PhotosUI
|
||||||
|
import Lottie
|
||||||
|
|
||||||
// MARK: - AttachmentPanelView
|
// MARK: - AttachmentPanelView
|
||||||
|
|
||||||
@@ -33,6 +34,9 @@ struct AttachmentPanelView: View {
|
|||||||
@State private var capturedImage: UIImage?
|
@State private var capturedImage: UIImage?
|
||||||
@State private var captionText: String = ""
|
@State private var captionText: String = ""
|
||||||
@State private var previewAsset: IdentifiableAsset?
|
@State private var previewAsset: IdentifiableAsset?
|
||||||
|
/// Tab widths/origins for sliding selection indicator (RosettaTabBar parity).
|
||||||
|
@State private var tabWidths: [AttachmentTab: CGFloat] = [:]
|
||||||
|
@State private var tabOrigins: [AttachmentTab: CGFloat] = [:]
|
||||||
|
|
||||||
private var hasSelection: Bool { !selectedAssets.isEmpty }
|
private var hasSelection: Bool { !selectedAssets.isEmpty }
|
||||||
|
|
||||||
@@ -60,8 +64,7 @@ struct AttachmentPanelView: View {
|
|||||||
case .file:
|
case .file:
|
||||||
fileTabContent
|
fileTabContent
|
||||||
case .avatar:
|
case .avatar:
|
||||||
// Avatar is an action tab — handled in tabButton tap
|
avatarTabContent
|
||||||
Spacer()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
@@ -104,6 +107,8 @@ struct AttachmentPanelView: View {
|
|||||||
)
|
)
|
||||||
.background(TransparentFullScreenBackground())
|
.background(TransparentFullScreenBackground())
|
||||||
}
|
}
|
||||||
|
.onPreferenceChange(AttachTabWidthKey.self) { tabWidths.merge($0) { _, new in new } }
|
||||||
|
.onPreferenceChange(AttachTabOriginKey.self) { tabOrigins.merge($0) { _, new in new } }
|
||||||
.presentationDetents([.medium, .large])
|
.presentationDetents([.medium, .large])
|
||||||
.presentationDragIndicator(.hidden)
|
.presentationDragIndicator(.hidden)
|
||||||
.attachmentCornerRadius(20)
|
.attachmentCornerRadius(20)
|
||||||
@@ -200,19 +205,85 @@ struct AttachmentPanelView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Avatar Tab Content (Android parity: AttachAlertAvatarLayout)
|
||||||
|
|
||||||
|
/// Dedicated avatar tab with Lottie animation and "Send Avatar" button.
|
||||||
|
/// Android: `AttachAlertAvatarLayout.kt` — looping Lottie + title + subtitle + button.
|
||||||
|
private var avatarTabContent: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Lottie animation (Android: avatar.json, 100×100dp, infinite loop)
|
||||||
|
LottieView(
|
||||||
|
animationName: "avatar",
|
||||||
|
loopMode: .loop,
|
||||||
|
animationSpeed: 1.0
|
||||||
|
)
|
||||||
|
.frame(width: 100, height: 100)
|
||||||
|
|
||||||
|
// Title
|
||||||
|
Text("Send Avatar")
|
||||||
|
.font(.system(size: 20, weight: .semibold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
|
// Subtitle
|
||||||
|
Text("Share your profile avatar\nwith this contact")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundStyle(.white.opacity(0.5))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
// Send button (capsule style matching File tab's "Browse Files" button)
|
||||||
|
Button {
|
||||||
|
if hasAvatar {
|
||||||
|
onSendAvatar()
|
||||||
|
dismiss()
|
||||||
|
} else {
|
||||||
|
dismiss()
|
||||||
|
onSetAvatar?()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text(hasAvatar ? "Send Avatar" : "Set Avatar")
|
||||||
|
.font(.system(size: 15, weight: .medium))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(Color(hex: 0x008BFF), in: Capsule())
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Reserve space for tab bar so content doesn't get clipped
|
||||||
|
Color.clear.frame(height: 70)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - File Tab Content
|
// MARK: - File Tab Content
|
||||||
|
|
||||||
private var fileTabContent: some View {
|
private var fileTabContent: some View {
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: 16) {
|
||||||
Spacer()
|
Spacer()
|
||||||
Image(systemName: "doc.fill")
|
|
||||||
.font(.system(size: 48))
|
|
||||||
.foregroundStyle(.white.opacity(0.3))
|
|
||||||
|
|
||||||
|
// Lottie animation (matching avatar tab layout)
|
||||||
|
LottieView(
|
||||||
|
animationName: "file_folder",
|
||||||
|
loopMode: .loop,
|
||||||
|
animationSpeed: 1.0
|
||||||
|
)
|
||||||
|
.frame(width: 100, height: 100)
|
||||||
|
|
||||||
|
// Title
|
||||||
|
Text("Send File")
|
||||||
|
.font(.system(size: 20, weight: .semibold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
|
// Subtitle
|
||||||
Text("Select a file to send")
|
Text("Select a file to send")
|
||||||
.font(.system(size: 16))
|
.font(.system(size: 14))
|
||||||
.foregroundStyle(.white.opacity(0.5))
|
.foregroundStyle(.white.opacity(0.5))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
// Browse button (capsule style matching avatar tab)
|
||||||
Button {
|
Button {
|
||||||
showFilePicker = true
|
showFilePicker = true
|
||||||
} label: {
|
} label: {
|
||||||
@@ -225,6 +296,9 @@ struct AttachmentPanelView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
|
// Reserve space for tab bar so content doesn't get clipped
|
||||||
|
Color.clear.frame(height: 70)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.task {
|
.task {
|
||||||
@@ -315,28 +389,66 @@ struct AttachmentPanelView: View {
|
|||||||
TelegramGlassRoundedRect(cornerRadius: 21)
|
TelegramGlassRoundedRect(cornerRadius: 21)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Tab Bar (Figma: glass capsule, 3 tabs)
|
// MARK: - Tab Bar (RosettaTabBar parity: glass capsule + sliding indicator)
|
||||||
|
|
||||||
/// Glass capsule tab bar matching RosettaTabBar pattern.
|
/// Glass capsule tab bar matching RosettaTabBar pattern exactly.
|
||||||
/// Tabs: Gallery | File | Avatar.
|
/// Tabs: Gallery | File | Avatar.
|
||||||
/// Colors from RosettaTabBar: selected=#008BFF, unselected=white.
|
/// Selection indicator: sliding glass pill behind selected tab (spring animation).
|
||||||
/// Background: .regularMaterial (iOS < 26) / .glassEffect (iOS 26+).
|
|
||||||
private var tabBar: some View {
|
private var tabBar: some View {
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
tabButton(.gallery, icon: "photo.fill", label: "Gallery")
|
tabButton(.gallery, icon: "photo.fill", label: "Gallery")
|
||||||
|
.background(tabWidthReader(.gallery))
|
||||||
tabButton(.file, icon: "doc.fill", label: "File")
|
tabButton(.file, icon: "doc.fill", label: "File")
|
||||||
|
.background(tabWidthReader(.file))
|
||||||
tabButton(.avatar, icon: "person.crop.circle.fill", label: "Avatar")
|
tabButton(.avatar, icon: "person.crop.circle.fill", label: "Avatar")
|
||||||
|
.background(tabWidthReader(.avatar))
|
||||||
}
|
}
|
||||||
.padding(4)
|
.padding(4)
|
||||||
.background { tabBarBackground }
|
.coordinateSpace(name: "attachTabBar")
|
||||||
|
.background(alignment: .leading) {
|
||||||
|
// Sliding selection indicator (RosettaTabBar parity)
|
||||||
|
attachmentSelectionIndicator
|
||||||
|
}
|
||||||
|
.background { TelegramGlassCapsule() }
|
||||||
.clipShape(Capsule())
|
.clipShape(Capsule())
|
||||||
.contentShape(Capsule())
|
.contentShape(Capsule())
|
||||||
.tabBarShadow()
|
.tabBarShadow()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Glass background matching RosettaTabBar (lines 136–149).
|
/// Reads tab width and origin for selection indicator positioning.
|
||||||
private var tabBarBackground: some View {
|
private func tabWidthReader(_ tab: AttachmentTab) -> some View {
|
||||||
TelegramGlassCapsule()
|
GeometryReader { geo in
|
||||||
|
Color.clear.preference(
|
||||||
|
key: AttachTabOriginKey.self,
|
||||||
|
value: [tab: geo.frame(in: .named("attachTabBar")).origin.x]
|
||||||
|
)
|
||||||
|
.preference(
|
||||||
|
key: AttachTabWidthKey.self,
|
||||||
|
value: [tab: geo.size.width]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sliding glass pill behind the selected tab (matches RosettaTabBar).
|
||||||
|
@ViewBuilder
|
||||||
|
private var attachmentSelectionIndicator: some View {
|
||||||
|
let width = tabWidths[selectedTab] ?? 0
|
||||||
|
let xOffset = tabOrigins[selectedTab] ?? 0
|
||||||
|
|
||||||
|
if #available(iOS 26, *) {
|
||||||
|
Capsule().fill(.clear)
|
||||||
|
.glassEffect(.regular, in: .capsule)
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
.frame(width: width)
|
||||||
|
.offset(x: xOffset)
|
||||||
|
.animation(.spring(response: 0.34, dampingFraction: 0.82), value: selectedTab)
|
||||||
|
} else {
|
||||||
|
Capsule().fill(.thinMaterial)
|
||||||
|
.frame(width: max(0, width - 4))
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.offset(x: xOffset + 2)
|
||||||
|
.animation(.spring(response: 0.34, dampingFraction: 0.82), value: selectedTab)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Individual tab button matching RosettaTabBar dimensions exactly.
|
/// Individual tab button matching RosettaTabBar dimensions exactly.
|
||||||
@@ -345,20 +457,8 @@ struct AttachmentPanelView: View {
|
|||||||
let isSelected = selectedTab == tab
|
let isSelected = selectedTab == tab
|
||||||
|
|
||||||
return Button {
|
return Button {
|
||||||
if tab == .avatar {
|
withAnimation(.spring(response: 0.34, dampingFraction: 0.82)) {
|
||||||
if hasAvatar {
|
selectedTab = tab
|
||||||
onSendAvatar()
|
|
||||||
dismiss()
|
|
||||||
} else {
|
|
||||||
// No avatar set — offer to set one
|
|
||||||
dismiss()
|
|
||||||
onSetAvatar?()
|
|
||||||
}
|
|
||||||
return
|
|
||||||
} else {
|
|
||||||
withAnimation(.easeInOut(duration: 0.2)) {
|
|
||||||
selectedTab = tab
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
VStack(spacing: 2) {
|
VStack(spacing: 2) {
|
||||||
@@ -372,11 +472,6 @@ struct AttachmentPanelView: View {
|
|||||||
.foregroundStyle(isSelected ? Color(hex: 0x008BFF) : .white)
|
.foregroundStyle(isSelected ? Color(hex: 0x008BFF) : .white)
|
||||||
.frame(minWidth: 66, maxWidth: .infinity)
|
.frame(minWidth: 66, maxWidth: .infinity)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.background {
|
|
||||||
if isSelected {
|
|
||||||
TelegramGlassCapsule()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
@@ -457,6 +552,22 @@ private enum AttachmentTab: Hashable {
|
|||||||
case avatar
|
case avatar
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Tab Bar Preference Keys (selection indicator positioning)
|
||||||
|
|
||||||
|
private struct AttachTabWidthKey: PreferenceKey {
|
||||||
|
static var defaultValue: [AttachmentTab: CGFloat] = [:]
|
||||||
|
static func reduce(value: inout [AttachmentTab: CGFloat], nextValue: () -> [AttachmentTab: CGFloat]) {
|
||||||
|
value.merge(nextValue()) { _, new in new }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct AttachTabOriginKey: PreferenceKey {
|
||||||
|
static var defaultValue: [AttachmentTab: CGFloat] = [:]
|
||||||
|
static func reduce(value: inout [AttachmentTab: CGFloat], nextValue: () -> [AttachmentTab: CGFloat]) {
|
||||||
|
value.merge(nextValue()) { _, new in new }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - IdentifiableAsset
|
// MARK: - IdentifiableAsset
|
||||||
|
|
||||||
/// Wrapper to make PHAsset usable with SwiftUI `.fullScreenCover(item:)`.
|
/// Wrapper to make PHAsset usable with SwiftUI `.fullScreenCover(item:)`.
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ struct ChatDetailView: View {
|
|||||||
}
|
}
|
||||||
.overlay { chatEdgeGradients }
|
.overlay { chatEdgeGradients }
|
||||||
// FPS overlay — uncomment for performance testing:
|
// FPS overlay — uncomment for performance testing:
|
||||||
// .overlay { FPSOverlayView() }
|
.overlay { FPSOverlayView() }
|
||||||
.overlay(alignment: .bottom) {
|
.overlay(alignment: .bottom) {
|
||||||
if !route.isSystemAccount {
|
if !route.isSystemAccount {
|
||||||
KeyboardPaddedView {
|
KeyboardPaddedView {
|
||||||
@@ -466,10 +466,11 @@ private extension ChatDetailView {
|
|||||||
color: .white
|
color: .white
|
||||||
)
|
)
|
||||||
.frame(width: 11, height: 20)
|
.frame(width: 11, height: 20)
|
||||||
.allowsHitTesting(false)
|
.frame(width: 36, height: 36)
|
||||||
|
.contentShape(Circle())
|
||||||
|
.background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white) }
|
||||||
}
|
}
|
||||||
.frame(width: 36, height: 36)
|
.buttonStyle(.plain)
|
||||||
.background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white) }
|
|
||||||
.accessibilityLabel("Back")
|
.accessibilityLabel("Back")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -479,6 +480,7 @@ private extension ChatDetailView {
|
|||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.frame(minWidth: 120)
|
.frame(minWidth: 120)
|
||||||
.frame(height: 44)
|
.frame(height: 44)
|
||||||
|
.contentShape(Capsule())
|
||||||
.background {
|
.background {
|
||||||
glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: .white)
|
glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: .white)
|
||||||
}
|
}
|
||||||
@@ -490,6 +492,7 @@ private extension ChatDetailView {
|
|||||||
Button { openProfile() } label: {
|
Button { openProfile() } label: {
|
||||||
ChatDetailToolbarAvatar(route: route, size: 35)
|
ChatDetailToolbarAvatar(route: route, size: 35)
|
||||||
.frame(width: 36, height: 36)
|
.frame(width: 36, height: 36)
|
||||||
|
.contentShape(Circle())
|
||||||
.background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white) }
|
.background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white) }
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
@@ -508,6 +511,7 @@ private extension ChatDetailView {
|
|||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.frame(minWidth: 120)
|
.frame(minWidth: 120)
|
||||||
.frame(height: 44)
|
.frame(height: 44)
|
||||||
|
.contentShape(Capsule())
|
||||||
.background {
|
.background {
|
||||||
glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: .white)
|
glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: .white)
|
||||||
}
|
}
|
||||||
@@ -519,6 +523,7 @@ private extension ChatDetailView {
|
|||||||
Button { openProfile() } label: {
|
Button { openProfile() } label: {
|
||||||
ChatDetailToolbarAvatar(route: route, size: 38)
|
ChatDetailToolbarAvatar(route: route, size: 38)
|
||||||
.frame(width: 44, height: 44)
|
.frame(width: 44, height: 44)
|
||||||
|
.contentShape(Circle())
|
||||||
.background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white) }
|
.background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white) }
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
@@ -533,10 +538,10 @@ private extension ChatDetailView {
|
|||||||
color: .white
|
color: .white
|
||||||
)
|
)
|
||||||
.frame(width: 11, height: 20)
|
.frame(width: 11, height: 20)
|
||||||
.allowsHitTesting(false)
|
|
||||||
.frame(width: 36, height: 36)
|
.frame(width: 36, height: 36)
|
||||||
.frame(height: 44)
|
.frame(height: 44)
|
||||||
.padding(.horizontal, 4)
|
.padding(.horizontal, 4)
|
||||||
|
.contentShape(Capsule())
|
||||||
.background {
|
.background {
|
||||||
glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: .white)
|
glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: .white)
|
||||||
}
|
}
|
||||||
@@ -790,9 +795,8 @@ private extension ChatDetailView {
|
|||||||
scroll
|
scroll
|
||||||
.scrollIndicators(.hidden)
|
.scrollIndicators(.hidden)
|
||||||
.overlay(alignment: .bottom) {
|
.overlay(alignment: .bottom) {
|
||||||
KeyboardPaddedView(extraPadding: composerHeight + 4) {
|
scrollToBottomButton(proxy: proxy)
|
||||||
scrollToBottomButton(proxy: proxy)
|
.padding(.bottom, composerHeight + 4)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1766,6 +1770,13 @@ private extension ChatDetailView {
|
|||||||
func openProfile() {
|
func openProfile() {
|
||||||
guard !route.isSavedMessages, !route.isSystemAccount else { return }
|
guard !route.isSavedMessages, !route.isSystemAccount else { return }
|
||||||
isInputFocused = false
|
isInputFocused = false
|
||||||
|
// Force-dismiss keyboard at UIKit level immediately.
|
||||||
|
// On iOS 26+, the async resignFirstResponder via syncFocus races with
|
||||||
|
// the navigation transition — the system may re-focus the text view
|
||||||
|
// when returning from the profile, causing a ghost keyboard.
|
||||||
|
UIApplication.shared.sendAction(
|
||||||
|
#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil
|
||||||
|
)
|
||||||
showOpponentProfile = true
|
showOpponentProfile = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2109,17 +2120,31 @@ private extension ChatDetailView {
|
|||||||
func replyBar(for message: ChatMessage) -> some View {
|
func replyBar(for message: ChatMessage) -> some View {
|
||||||
let senderName = senderDisplayName(for: message.fromPublicKey)
|
let senderName = senderDisplayName(for: message.fromPublicKey)
|
||||||
let previewText: String = {
|
let previewText: String = {
|
||||||
let trimmed = message.text.trimmingCharacters(in: .whitespaces)
|
// Attachment type labels — check BEFORE text so photo/avatar messages
|
||||||
if !trimmed.isEmpty { return message.text }
|
// always show their type even if text contains invisible characters.
|
||||||
if message.attachments.contains(where: { $0.type == .image }) { return "Photo" }
|
if message.attachments.contains(where: { $0.type == .image }) {
|
||||||
|
let caption = message.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return caption.isEmpty ? "Photo" : caption
|
||||||
|
}
|
||||||
if let file = message.attachments.first(where: { $0.type == .file }) {
|
if let file = message.attachments.first(where: { $0.type == .file }) {
|
||||||
|
let caption = message.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !caption.isEmpty { return caption }
|
||||||
|
// Parse filename from preview (tag::fileSize::fileName)
|
||||||
|
let parts = file.preview.components(separatedBy: "::")
|
||||||
|
if parts.count >= 3 { return parts[2] }
|
||||||
return file.id.isEmpty ? "File" : file.id
|
return file.id.isEmpty ? "File" : file.id
|
||||||
}
|
}
|
||||||
if message.attachments.contains(where: { $0.type == .avatar }) { return "Avatar" }
|
if message.attachments.contains(where: { $0.type == .avatar }) { return "Avatar" }
|
||||||
if message.attachments.contains(where: { $0.type == .messages }) { return "Forwarded message" }
|
if message.attachments.contains(where: { $0.type == .messages }) { return "Forwarded message" }
|
||||||
|
// No known attachment type — fall back to text
|
||||||
|
let trimmed = message.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !trimmed.isEmpty { return message.text }
|
||||||
if !message.attachments.isEmpty { return "Attachment" }
|
if !message.attachments.isEmpty { return "Attachment" }
|
||||||
return ""
|
return ""
|
||||||
}()
|
}()
|
||||||
|
#if DEBUG
|
||||||
|
let _ = print("📋 REPLY: preview='\(previewText.prefix(30))' text='\(message.text.prefix(30))' textHex=\(Array(message.text.utf8).prefix(16).map { String(format: "%02x", $0) }.joined(separator: " ")) atts=\(message.attachments.count) types=\(message.attachments.map { $0.type.rawValue })")
|
||||||
|
#endif
|
||||||
|
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
RoundedRectangle(cornerRadius: 1.5)
|
RoundedRectangle(cornerRadius: 1.5)
|
||||||
|
|||||||
@@ -37,24 +37,15 @@ struct FullScreenImageViewer: View {
|
|||||||
.opacity(backgroundOpacity)
|
.opacity(backgroundOpacity)
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
|
|
||||||
// Zoomable image
|
// Zoomable image (visual only — no gestures here)
|
||||||
Image(uiImage: image)
|
Image(uiImage: image)
|
||||||
.resizable()
|
.resizable()
|
||||||
.scaledToFit()
|
.scaledToFit()
|
||||||
.scaleEffect(scale)
|
.scaleEffect(scale)
|
||||||
.offset(x: offset.width, y: offset.height + dismissOffset)
|
.offset(x: offset.width, y: offset.height + dismissOffset)
|
||||||
.gesture(dragGesture)
|
.allowsHitTesting(false)
|
||||||
.gesture(pinchGesture)
|
|
||||||
.onTapGesture(count: 2) {
|
|
||||||
doubleTap()
|
|
||||||
}
|
|
||||||
.onTapGesture(count: 1) {
|
|
||||||
withAnimation(.easeInOut(duration: 0.2)) {
|
|
||||||
showControls.toggle()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close button
|
// Close button (above gesture layer so it stays tappable)
|
||||||
if showControls {
|
if showControls {
|
||||||
VStack {
|
VStack {
|
||||||
HStack {
|
HStack {
|
||||||
@@ -77,6 +68,20 @@ struct FullScreenImageViewer: View {
|
|||||||
.transition(.opacity)
|
.transition(.opacity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Gestures on the full-screen ZStack — not on the Image.
|
||||||
|
// scaleEffect is visual-only and doesn't expand the Image's hit-test area,
|
||||||
|
// so when zoomed to 2.5x, taps outside the original frame were lost.
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture(count: 2) {
|
||||||
|
doubleTap()
|
||||||
|
}
|
||||||
|
.onTapGesture(count: 1) {
|
||||||
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
|
showControls.toggle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.simultaneousGesture(pinchGesture)
|
||||||
|
.simultaneousGesture(dragGesture)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Background Opacity
|
// MARK: - Background Opacity
|
||||||
|
|||||||
@@ -134,7 +134,11 @@ struct ImageGalleryViewer: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||||
.disabled(currentZoomScale > 1.05 || isDismissing)
|
// Block TabView page swipe when zoomed or dismissing,
|
||||||
|
// but ONLY the scroll — NOT all user interaction.
|
||||||
|
// .disabled() kills ALL gestures (taps, pinch, etc.) which prevents
|
||||||
|
// double-tap zoom out. .scrollDisabled() only blocks the page swipe.
|
||||||
|
.scrollDisabled(currentZoomScale > 1.05 || isDismissing)
|
||||||
.opacity(presentationAlpha)
|
.opacity(presentationAlpha)
|
||||||
|
|
||||||
// Controls overlay
|
// Controls overlay
|
||||||
|
|||||||
@@ -199,6 +199,17 @@ struct MessageAvatarView: View {
|
|||||||
if let cached = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) {
|
if let cached = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) {
|
||||||
avatarImage = cached
|
avatarImage = cached
|
||||||
showAvatar = true // No animation for cached — show immediately
|
showAvatar = true // No animation for cached — show immediately
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Outgoing avatar: sender is me — load from AvatarRepository (always available locally)
|
||||||
|
if outgoing {
|
||||||
|
let myKey = SessionManager.shared.currentPublicKey
|
||||||
|
if let myAvatar = AvatarRepository.shared.loadAvatar(publicKey: myKey) {
|
||||||
|
avatarImage = myAvatar
|
||||||
|
showAvatar = true
|
||||||
|
// Backfill AttachmentCache so next render hits fast path
|
||||||
|
AttachmentCache.shared.saveImage(myAvatar, forAttachmentId: attachment.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,23 @@ struct ZoomableImagePage: View {
|
|||||||
.scaleEffect(effectiveScale)
|
.scaleEffect(effectiveScale)
|
||||||
.offset(x: effectiveScale > 1.05 ? zoomOffset.width : 0,
|
.offset(x: effectiveScale > 1.05 ? zoomOffset.width : 0,
|
||||||
y: (effectiveScale > 1.05 ? zoomOffset.height : 0) + dismissDragOffset)
|
y: (effectiveScale > 1.05 ? zoomOffset.height : 0) + dismissDragOffset)
|
||||||
|
// Expand hit-test area to full screen — scaleEffect is visual-only
|
||||||
|
// and doesn't grow the Image's gesture frame. Without this,
|
||||||
|
// double-tap to zoom out doesn't work on zoomed-in edges.
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
// Double tap: zoom to 2.5x or reset (MUST be before single tap)
|
||||||
|
.onTapGesture(count: 2) {
|
||||||
|
withAnimation(.spring(response: 0.35, dampingFraction: 0.9)) {
|
||||||
|
if zoomScale > 1.1 {
|
||||||
|
zoomScale = 1.0
|
||||||
|
zoomOffset = .zero
|
||||||
|
} else {
|
||||||
|
zoomScale = 2.5
|
||||||
|
}
|
||||||
|
currentScale = zoomScale
|
||||||
|
}
|
||||||
|
}
|
||||||
// Single tap: toggle controls / edge navigation
|
// Single tap: toggle controls / edge navigation
|
||||||
.onTapGesture { location in
|
.onTapGesture { location in
|
||||||
let width = UIScreen.main.bounds.width
|
let width = UIScreen.main.bounds.width
|
||||||
@@ -47,20 +64,8 @@ struct ZoomableImagePage: View {
|
|||||||
showControls.toggle()
|
showControls.toggle()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Double tap: zoom to 2.5x or reset
|
|
||||||
.onTapGesture(count: 2) {
|
|
||||||
withAnimation(.spring(response: 0.35, dampingFraction: 0.9)) {
|
|
||||||
if zoomScale > 1.1 {
|
|
||||||
zoomScale = 1.0
|
|
||||||
zoomOffset = .zero
|
|
||||||
} else {
|
|
||||||
zoomScale = 2.5
|
|
||||||
}
|
|
||||||
currentScale = zoomScale
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Pinch zoom
|
// Pinch zoom
|
||||||
.gesture(
|
.simultaneousGesture(
|
||||||
MagnifyGesture()
|
MagnifyGesture()
|
||||||
.updating($pinchScale) { value, state, _ in
|
.updating($pinchScale) { value, state, _ in
|
||||||
state = value.magnification
|
state = value.magnification
|
||||||
@@ -78,7 +83,7 @@ struct ZoomableImagePage: View {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
// Pan when zoomed
|
// Pan when zoomed
|
||||||
.gesture(
|
.simultaneousGesture(
|
||||||
zoomScale > 1.05 ?
|
zoomScale > 1.05 ?
|
||||||
DragGesture()
|
DragGesture()
|
||||||
.onChanged { value in
|
.onChanged { value in
|
||||||
@@ -96,7 +101,6 @@ struct ZoomableImagePage: View {
|
|||||||
.simultaneousGesture(
|
.simultaneousGesture(
|
||||||
zoomScale <= 1.05 ? dismissDragGesture : nil
|
zoomScale <= 1.05 ? dismissDragGesture : nil
|
||||||
)
|
)
|
||||||
.contentShape(Rectangle())
|
|
||||||
} else {
|
} else {
|
||||||
placeholder
|
placeholder
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,6 +106,21 @@ struct ChatListView: View {
|
|||||||
guard let route = notification.object as? ChatRoute else { return }
|
guard let route = notification.object as? ChatRoute else { return }
|
||||||
// Navigate to the chat from push notification tap
|
// Navigate to the chat from push notification tap
|
||||||
navigationState.path = [route]
|
navigationState.path = [route]
|
||||||
|
// Clear pending route — consumed by onReceive (fast path)
|
||||||
|
AppDelegate.pendingChatRoute = nil
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
// Fallback: consume pending route if .onReceive missed it.
|
||||||
|
// Handles terminated app (ChatListView didn't exist when notification was posted)
|
||||||
|
// and background app (Combine subscription may not fire during app resume).
|
||||||
|
if let route = AppDelegate.pendingChatRoute {
|
||||||
|
AppDelegate.pendingChatRoute = nil
|
||||||
|
// Small delay to let NavigationStack settle after view creation
|
||||||
|
Task { @MainActor in
|
||||||
|
try? await Task.sleep(nanoseconds: 100_000_000) // 100ms
|
||||||
|
navigationState.path = [route]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,14 @@ struct MainTabView: View {
|
|||||||
// never observes the dialogs dictionary directly.
|
// never observes the dialogs dictionary directly.
|
||||||
UnreadCountObserver(count: $cachedUnreadCount)
|
UnreadCountObserver(count: $cachedUnreadCount)
|
||||||
}
|
}
|
||||||
|
// Switch to Chats tab when user taps a push notification.
|
||||||
|
// Without this, the navigation happens in the Chats NavigationStack
|
||||||
|
// but the user stays on whatever tab they were on (e.g., Settings).
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: .openChatFromNotification)) { _ in
|
||||||
|
if selectedTab != .chats {
|
||||||
|
selectedTab = .chats
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - iOS 26+ (native TabView with liquid glass tab bar)
|
// MARK: - iOS 26+ (native TabView with liquid glass tab bar)
|
||||||
|
|||||||
1
Rosetta/Resources/Lottie/avatar.json
Normal file
1
Rosetta/Resources/Lottie/avatar.json
Normal file
File diff suppressed because one or more lines are too long
1
Rosetta/Resources/Lottie/file_folder.json
Normal file
1
Rosetta/Resources/Lottie/file_folder.json
Normal file
File diff suppressed because one or more lines are too long
@@ -1,6 +1,7 @@
|
|||||||
import FirebaseCore
|
import FirebaseCore
|
||||||
import FirebaseCrashlytics
|
import FirebaseCrashlytics
|
||||||
import FirebaseMessaging
|
import FirebaseMessaging
|
||||||
|
import Intents
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
|
|
||||||
@@ -9,6 +10,11 @@ import UserNotifications
|
|||||||
final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate,
|
final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate,
|
||||||
MessagingDelegate
|
MessagingDelegate
|
||||||
{
|
{
|
||||||
|
/// Pending chat route from notification tap — consumed by ChatListView on appear.
|
||||||
|
/// Handles both terminated app (notification posted before ChatListView exists)
|
||||||
|
/// and background app (fallback if .onReceive misses the synchronous post).
|
||||||
|
static var pendingChatRoute: ChatRoute?
|
||||||
|
|
||||||
func application(
|
func application(
|
||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
|
||||||
@@ -137,9 +143,40 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
|
|||||||
// Always set sender_public_key in userInfo for notification tap navigation.
|
// Always set sender_public_key in userInfo for notification tap navigation.
|
||||||
content.userInfo = ["sender_public_key": senderKey, "sender_name": senderName]
|
content.userInfo = ["sender_public_key": senderKey, "sender_name": senderName]
|
||||||
|
|
||||||
|
// Communication Notification via INSendMessageIntent (CarPlay + Focus parity).
|
||||||
|
let handle = INPersonHandle(value: senderKey, type: .unknown)
|
||||||
|
let sender = INPerson(
|
||||||
|
personHandle: handle,
|
||||||
|
nameComponents: nil,
|
||||||
|
displayName: senderName.isEmpty ? "Rosetta" : senderName,
|
||||||
|
image: nil,
|
||||||
|
contactIdentifier: nil,
|
||||||
|
customIdentifier: senderKey
|
||||||
|
)
|
||||||
|
let intent = INSendMessageIntent(
|
||||||
|
recipients: nil,
|
||||||
|
outgoingMessageType: .outgoingMessageText,
|
||||||
|
content: messageText,
|
||||||
|
speakableGroupName: nil,
|
||||||
|
conversationIdentifier: senderKey,
|
||||||
|
serviceName: "Rosetta",
|
||||||
|
sender: sender,
|
||||||
|
attachments: nil
|
||||||
|
)
|
||||||
|
let interaction = INInteraction(intent: intent, response: nil)
|
||||||
|
interaction.direction = .incoming
|
||||||
|
interaction.donate(completion: nil)
|
||||||
|
|
||||||
|
let finalContent: UNNotificationContent
|
||||||
|
if let updated = try? content.updating(from: intent) {
|
||||||
|
finalContent = updated
|
||||||
|
} else {
|
||||||
|
finalContent = content
|
||||||
|
}
|
||||||
|
|
||||||
let request = UNNotificationRequest(
|
let request = UNNotificationRequest(
|
||||||
identifier: "msg_\(senderKey)_\(Int(now))",
|
identifier: "msg_\(senderKey)_\(Int(now))",
|
||||||
content: content,
|
content: finalContent,
|
||||||
trigger: nil
|
trigger: nil
|
||||||
)
|
)
|
||||||
UNUserNotificationCenter.current().add(request) { _ in
|
UNUserNotificationCenter.current().add(request) { _ in
|
||||||
@@ -212,6 +249,10 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
|
|||||||
username: "",
|
username: "",
|
||||||
verified: 0
|
verified: 0
|
||||||
)
|
)
|
||||||
|
// Store pending route BEFORE posting — handles terminated app case
|
||||||
|
// where ChatListView doesn't exist yet, and background app case
|
||||||
|
// where .onReceive might miss the synchronous post.
|
||||||
|
Self.pendingChatRoute = route
|
||||||
NotificationCenter.default.post(
|
NotificationCenter.default.post(
|
||||||
name: .openChatFromNotification,
|
name: .openChatFromNotification,
|
||||||
object: route
|
object: route
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import UserNotifications
|
import UserNotifications
|
||||||
|
import Intents
|
||||||
|
|
||||||
/// Notification Service Extension — runs as a separate process even when the main app
|
/// Notification Service Extension — runs as a separate process even when the main app
|
||||||
/// is terminated. Intercepts push notifications with `mutable-content: 1` and:
|
/// is terminated. Intercepts push notifications with `mutable-content: 1` and:
|
||||||
@@ -6,6 +7,7 @@ import UserNotifications
|
|||||||
/// 2. Increments the app icon badge from shared App Group storage
|
/// 2. Increments the app icon badge from shared App Group storage
|
||||||
/// 3. Normalizes sender_public_key in userInfo (Android parity: multi-key fallback)
|
/// 3. Normalizes sender_public_key in userInfo (Android parity: multi-key fallback)
|
||||||
/// 4. Filters muted chats
|
/// 4. Filters muted chats
|
||||||
|
/// 5. Creates Communication Notification via INSendMessageIntent (CarPlay + Focus parity)
|
||||||
final class NotificationService: UNNotificationServiceExtension {
|
final class NotificationService: UNNotificationServiceExtension {
|
||||||
|
|
||||||
private static let appGroupID = "group.com.rosetta.dev"
|
private static let appGroupID = "group.com.rosetta.dev"
|
||||||
@@ -76,7 +78,18 @@ final class NotificationService: UNNotificationServiceExtension {
|
|||||||
content.categoryIdentifier = "message"
|
content.categoryIdentifier = "message"
|
||||||
}
|
}
|
||||||
|
|
||||||
contentHandler(content)
|
// 6. Create Communication Notification via INSendMessageIntent.
|
||||||
|
// This makes the notification appear on CarPlay and work with Focus filters.
|
||||||
|
// Apple requires INSendMessageIntent for messaging notifications on CarPlay (iOS 15+).
|
||||||
|
let senderName = Self.firstNonBlank(content.userInfo, keys: Self.senderNameKeyNames)
|
||||||
|
?? content.title
|
||||||
|
let finalContent = Self.makeCommunicationNotification(
|
||||||
|
content: content,
|
||||||
|
senderName: senderName,
|
||||||
|
senderKey: senderKey
|
||||||
|
)
|
||||||
|
|
||||||
|
contentHandler(finalContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
override func serviceExtensionTimeWillExpire() {
|
override func serviceExtensionTimeWillExpire() {
|
||||||
@@ -86,6 +99,56 @@ final class NotificationService: UNNotificationServiceExtension {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Communication Notification (CarPlay + Focus)
|
||||||
|
|
||||||
|
/// Wraps the notification content with an INSendMessageIntent so iOS treats it
|
||||||
|
/// as a Communication Notification. This enables:
|
||||||
|
/// - Display on CarPlay
|
||||||
|
/// - Proper grouping in Focus modes
|
||||||
|
/// - Sender name/avatar in notification UI
|
||||||
|
private static func makeCommunicationNotification(
|
||||||
|
content: UNMutableNotificationContent,
|
||||||
|
senderName: String,
|
||||||
|
senderKey: String
|
||||||
|
) -> UNNotificationContent {
|
||||||
|
let handle = INPersonHandle(value: senderKey, type: .unknown)
|
||||||
|
let sender = INPerson(
|
||||||
|
personHandle: handle,
|
||||||
|
nameComponents: nil,
|
||||||
|
displayName: senderName.isEmpty ? "Rosetta" : senderName,
|
||||||
|
image: nil,
|
||||||
|
contactIdentifier: nil,
|
||||||
|
customIdentifier: senderKey
|
||||||
|
)
|
||||||
|
|
||||||
|
let intent = INSendMessageIntent(
|
||||||
|
recipients: nil,
|
||||||
|
outgoingMessageType: .outgoingMessageText,
|
||||||
|
content: content.body,
|
||||||
|
speakableGroupName: nil,
|
||||||
|
conversationIdentifier: senderKey,
|
||||||
|
serviceName: "Rosetta",
|
||||||
|
sender: sender,
|
||||||
|
attachments: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
// Donate the intent so Siri can learn communication patterns.
|
||||||
|
let interaction = INInteraction(intent: intent, response: nil)
|
||||||
|
interaction.direction = .incoming
|
||||||
|
interaction.donate(completion: nil)
|
||||||
|
|
||||||
|
// Update the notification content with the intent.
|
||||||
|
// This returns a new content object that iOS recognizes as a Communication Notification.
|
||||||
|
do {
|
||||||
|
let updatedContent = try content.updating(from: intent)
|
||||||
|
return updatedContent
|
||||||
|
} catch {
|
||||||
|
// If updating fails, return original content — notification still works,
|
||||||
|
// just without CarPlay / Communication Notification features.
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
|
|
||||||
/// Android parity: extract sender key from multiple possible key names.
|
/// Android parity: extract sender key from multiple possible key names.
|
||||||
|
|||||||
Reference in New Issue
Block a user