Уведомления CarPlay, панель вложений с Lottie, фикс reply preview, плавная анимация клавиатуры, стабильность WebSocket
This commit is contained in:
@@ -323,7 +323,9 @@ final class ProtocolManager: @unchecked Sendable {
|
||||
|
||||
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 = Task { [weak self] in
|
||||
do {
|
||||
@@ -333,8 +335,13 @@ final class ProtocolManager: @unchecked Sendable {
|
||||
}
|
||||
guard let self, !Task.isCancelled else { return }
|
||||
if !self.handshakeComplete {
|
||||
Self.logger.error("Handshake timeout")
|
||||
self.client.disconnect()
|
||||
Self.logger.error("Handshake timeout — forcing reconnect")
|
||||
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
|
||||
guard let self else { return }
|
||||
// Ignore errors from old sockets after forceReconnect.
|
||||
guard task === self.webSocketTask else { return }
|
||||
if let error {
|
||||
Self.logger.error("Send error: \(error.localizedDescription)")
|
||||
onFailure?(error)
|
||||
@@ -138,9 +140,12 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
|
||||
func sendText(_ text: String) {
|
||||
guard let task = webSocketTask else { return }
|
||||
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 {
|
||||
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?) {
|
||||
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 }
|
||||
// Android parity: reset isConnecting on successful open (Protocol.kt onOpen).
|
||||
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?) {
|
||||
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
|
||||
isConnected = false
|
||||
handleDisconnect(error: nil)
|
||||
@@ -189,6 +204,8 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
|
||||
|
||||
task.receive { [weak self] result in
|
||||
guard let self, !isManuallyClosed else { return }
|
||||
// Ignore callbacks from old (cancelled) sockets after forceReconnect.
|
||||
guard task === self.webSocketTask else { return }
|
||||
|
||||
switch result {
|
||||
case .success(let message):
|
||||
|
||||
@@ -285,16 +285,15 @@ final class SessionManager {
|
||||
password: attachmentPassword
|
||||
)
|
||||
|
||||
// Upload encrypted blob to transport server (desktop: uploadFile)
|
||||
let tag = try await TransportManager.shared.uploadFile(
|
||||
id: attachmentId,
|
||||
content: Data(encryptedBlob.utf8)
|
||||
)
|
||||
|
||||
// Desktop parity: preview = "tag::blurhash" (same as IMAGE attachments)
|
||||
// Cache avatar locally BEFORE upload so outgoing avatar shows instantly
|
||||
// (same pattern as sendMessageWithAttachments — AttachmentCache.saveImage before upload).
|
||||
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 preview = "\(tag)::\(blurhash)"
|
||||
|
||||
// Build aesChachaKey with Latin-1 payload (desktop sync parity)
|
||||
let aesChachaPayload = Data(latin1ForSync.utf8)
|
||||
@@ -303,7 +302,7 @@ final class SessionManager {
|
||||
password: privKey
|
||||
)
|
||||
|
||||
// Build packet with avatar attachment
|
||||
// Build packet with avatar attachment — preview will be updated with tag after upload
|
||||
var packet = PacketMessage()
|
||||
packet.fromPublicKey = currentPublicKey
|
||||
packet.toPublicKey = toPublicKey
|
||||
@@ -316,8 +315,8 @@ final class SessionManager {
|
||||
packet.attachments = [
|
||||
MessageAttachment(
|
||||
id: attachmentId,
|
||||
preview: preview,
|
||||
blob: "", // Desktop parity: blob cleared after upload
|
||||
preview: blurhash, // Will be updated with "tag::blurhash" after upload
|
||||
blob: "",
|
||||
type: .avatar
|
||||
),
|
||||
]
|
||||
@@ -333,21 +332,40 @@ final class SessionManager {
|
||||
myPublicKey: currentPublicKey
|
||||
)
|
||||
|
||||
// Optimistic UI
|
||||
let isConnected = ProtocolManager.shared.connectionState == .authenticated
|
||||
let offlineAsSend = !isConnected
|
||||
// Optimistic UI — show message IMMEDIATELY (before upload)
|
||||
MessageRepository.shared.upsertFromMessagePacket(
|
||||
packet, myPublicKey: currentPublicKey, decryptedText: " ",
|
||||
attachmentPassword: "rawkey:" + encrypted.plainKeyAndNonce.hexString,
|
||||
fromSync: offlineAsSend
|
||||
fromSync: false
|
||||
)
|
||||
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)
|
||||
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
|
||||
// for cross-device avatar sync. Other devices receive via sync and
|
||||
// update their local avatar cache.
|
||||
|
||||
@@ -22,7 +22,7 @@ final class PerformanceLogger {
|
||||
|
||||
/// Enable for performance testing. Disabled by default — zero overhead in production.
|
||||
#if DEBUG
|
||||
var isEnabled = false // Set to `true` to enable perf logging
|
||||
var isEnabled = true // Set to `true` to enable perf logging
|
||||
#else
|
||||
var isEnabled = false
|
||||
#endif
|
||||
|
||||
@@ -11,26 +11,26 @@ enum ReleaseNotes {
|
||||
Entry(
|
||||
version: appVersion,
|
||||
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 animationNumber = 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
|
||||
// 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)
|
||||
// Cubic bezier control points — deterministic keyboard curve approximation.
|
||||
private var bezierP1x: CGFloat = 0.25
|
||||
private var bezierP1y: CGFloat = 0.1
|
||||
private var bezierP2x: CGFloat = 0.25
|
||||
@@ -86,6 +83,12 @@ final class KeyboardTracker: ObservableObject {
|
||||
if #available(iOS 26, *) { return }
|
||||
PerformanceLogger.shared.track("keyboard.kvo")
|
||||
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 {
|
||||
// Flush any pending KVO value and stop coalescing
|
||||
@@ -183,9 +186,19 @@ final class KeyboardTracker: ObservableObject {
|
||||
lastNotificationPadding = targetPadding
|
||||
|
||||
PerformanceLogger.shared.track("keyboard.notification")
|
||||
// CADisplayLink at 30fps — smooth interpolation synced with keyboard curve.
|
||||
// BubbleContextMenuOverlay.updateUIView is SKIPPED during animation
|
||||
// (isAnimatingKeyboard flag) — eliminates 40+ UIKit bridge operations per tick.
|
||||
#if DEBUG
|
||||
let direction = targetPadding > keyboardPadding ? "⬆️ SHOW" : "⬇️ HIDE"
|
||||
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 {
|
||||
isAnimatingKeyboard = true
|
||||
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) {
|
||||
animationNumber += 1
|
||||
animStartPadding = keyboardPadding
|
||||
animTargetPadding = target
|
||||
animStartTime = 0
|
||||
animStartTime = CACurrentMediaTime()
|
||||
animDuration = max(duration, 0.05)
|
||||
animTickCount = 0
|
||||
lastEased = 0
|
||||
|
||||
// Primary: sync with the keyboard's exact curve via presentation layer.
|
||||
// UIView.animate called HERE lands in the same Core Animation transaction
|
||||
// as the keyboard notification — identical timing function and start time.
|
||||
usingSyncAnimation = setupSyncAnimation(duration: duration, curveRaw: curveRaw)
|
||||
// Primary: sync view matches keyboard's exact curve (same CA transaction).
|
||||
// No lead, no pre-apply — the natural ~1 frame SwiftUI processing delay
|
||||
// creates the "keyboard pushes input" effect (like Telegram).
|
||||
let syncOK = setupSyncAnimation(duration: duration, curveRaw: curveRaw)
|
||||
|
||||
// Fallback: cubic bezier approximation (CSS "ease").
|
||||
if !usingSyncAnimation {
|
||||
// Fallback: cubic bezier (only if sync view can't be created).
|
||||
if !syncOK {
|
||||
configureBezier(curveRaw: curveRaw)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
proxy.isPaused = false
|
||||
} else {
|
||||
@@ -272,18 +286,13 @@ final class KeyboardTracker: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts a hidden UIView animation matching the keyboard's exact curve.
|
||||
/// Returns true if sync animation was set up successfully.
|
||||
/// Creates a hidden UIView animated with the keyboard's exact curve.
|
||||
private func setupSyncAnimation(duration: CFTimeInterval, curveRaw: Int) -> Bool {
|
||||
guard let window = UIApplication.shared.connectedScenes
|
||||
.compactMap({ $0 as? UIWindowScene }).first?.keyWindow else {
|
||||
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?.removeFromSuperview()
|
||||
|
||||
@@ -292,15 +301,8 @@ final class KeyboardTracker: ObservableObject {
|
||||
window.addSubview(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)
|
||||
UIView.animate(
|
||||
withDuration: duration,
|
||||
delay: 0,
|
||||
options: [options]
|
||||
) {
|
||||
UIView.animate(withDuration: duration, delay: 0, options: [options]) {
|
||||
view.alpha = 1
|
||||
}
|
||||
|
||||
@@ -308,33 +310,42 @@ final class KeyboardTracker: ObservableObject {
|
||||
}
|
||||
|
||||
private func animationTick() {
|
||||
let tickStart = CACurrentMediaTime()
|
||||
PerformanceLogger.shared.track("keyboard.animTick")
|
||||
animTickCount += 1
|
||||
|
||||
let now = CACurrentMediaTime()
|
||||
lastTickTime = now
|
||||
|
||||
// Get eased fraction — either from presentation layer (exact) or bezier (fallback).
|
||||
let eased: CGFloat
|
||||
var isComplete = false
|
||||
|
||||
if usingSyncAnimation, let presentation = syncView?.layer.presentation() {
|
||||
let fraction = CGFloat(presentation.opacity)
|
||||
eased = fraction
|
||||
isComplete = fraction >= 0.999
|
||||
} else {
|
||||
// Bezier fallback
|
||||
if animStartTime == 0 { animStartTime = now }
|
||||
let elapsed = now - animStartTime
|
||||
let timeComplete = elapsed >= animDuration
|
||||
|
||||
// Read eased fraction: sync view (exact) or bezier (fallback).
|
||||
var eased: CGFloat
|
||||
if let presentation = syncView?.layer.presentation() {
|
||||
eased = min(max(CGFloat(presentation.opacity), 0), 1)
|
||||
} else {
|
||||
let t = min(elapsed / animDuration, 1.0)
|
||||
eased = cubicBezierEase(t)
|
||||
isComplete = t >= 1.0
|
||||
}
|
||||
|
||||
// Guard: presentation layer can return NaN opacity during edge cases
|
||||
// (window transition, sync view removed). NaN propagating to keyboardPadding
|
||||
// causes `Color.clear.frame(height: NaN)` → CoreGraphics NaN errors → FPS freeze.
|
||||
// Enforce monotonic progress — sync view's presentation layer can give
|
||||
// non-monotonic values in the first 2 ticks while Core Animation commits
|
||||
// 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 {
|
||||
displayLinkProxy?.isPaused = true
|
||||
lastTickTime = 0
|
||||
@@ -342,28 +353,33 @@ final class KeyboardTracker: ObservableObject {
|
||||
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 rounded = max(0, round(raw))
|
||||
|
||||
if isComplete || animTickCount > 30 {
|
||||
if timeComplete || animTickCount > 30 {
|
||||
let prevPadding = keyboardPadding
|
||||
keyboardPadding = max(0, animTargetPadding)
|
||||
// Pause instead of invalidate — preserves vsync phase for next animation.
|
||||
displayLinkProxy?.isPaused = true
|
||||
lastTickTime = 0
|
||||
isAnimatingKeyboard = false
|
||||
} else if rounded != keyboardPadding {
|
||||
keyboardPadding = rounded
|
||||
}
|
||||
#if DEBUG
|
||||
let tickMs = (CACurrentMediaTime() - tickStart) * 1000
|
||||
if tickMs > 16 {
|
||||
PerformanceLogger.shared.track("keyboard.slowTick")
|
||||
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 {
|
||||
#if DEBUG
|
||||
let prevPad = keyboardPadding
|
||||
#endif
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cubic bezier fallback
|
||||
|
||||
@@ -380,7 +396,8 @@ final class KeyboardTracker: ObservableObject {
|
||||
bezierP1x = 0; bezierP1y = 0
|
||||
bezierP2x = 1.0; bezierP2y = 1.0
|
||||
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
|
||||
bezierP2x = 0.25; bezierP2y = 1.0
|
||||
}
|
||||
|
||||
@@ -113,6 +113,7 @@ struct CallsView: View {
|
||||
Text("Edit")
|
||||
.font(.system(size: 17, weight: .medium))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
.frame(height: 44)
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import SwiftUI
|
||||
import Photos
|
||||
import PhotosUI
|
||||
import Lottie
|
||||
|
||||
// MARK: - AttachmentPanelView
|
||||
|
||||
@@ -33,6 +34,9 @@ struct AttachmentPanelView: View {
|
||||
@State private var capturedImage: UIImage?
|
||||
@State private var captionText: String = ""
|
||||
@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 }
|
||||
|
||||
@@ -60,8 +64,7 @@ struct AttachmentPanelView: View {
|
||||
case .file:
|
||||
fileTabContent
|
||||
case .avatar:
|
||||
// Avatar is an action tab — handled in tabButton tap
|
||||
Spacer()
|
||||
avatarTabContent
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
@@ -104,6 +107,8 @@ struct AttachmentPanelView: View {
|
||||
)
|
||||
.background(TransparentFullScreenBackground())
|
||||
}
|
||||
.onPreferenceChange(AttachTabWidthKey.self) { tabWidths.merge($0) { _, new in new } }
|
||||
.onPreferenceChange(AttachTabOriginKey.self) { tabOrigins.merge($0) { _, new in new } }
|
||||
.presentationDetents([.medium, .large])
|
||||
.presentationDragIndicator(.hidden)
|
||||
.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
|
||||
|
||||
private var fileTabContent: some View {
|
||||
VStack(spacing: 20) {
|
||||
VStack(spacing: 16) {
|
||||
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")
|
||||
.font(.system(size: 16))
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
// Browse button (capsule style matching avatar tab)
|
||||
Button {
|
||||
showFilePicker = true
|
||||
} label: {
|
||||
@@ -225,6 +296,9 @@ struct AttachmentPanelView: View {
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Reserve space for tab bar so content doesn't get clipped
|
||||
Color.clear.frame(height: 70)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.task {
|
||||
@@ -315,28 +389,66 @@ struct AttachmentPanelView: View {
|
||||
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.
|
||||
/// Colors from RosettaTabBar: selected=#008BFF, unselected=white.
|
||||
/// Background: .regularMaterial (iOS < 26) / .glassEffect (iOS 26+).
|
||||
/// Selection indicator: sliding glass pill behind selected tab (spring animation).
|
||||
private var tabBar: some View {
|
||||
HStack(spacing: 0) {
|
||||
tabButton(.gallery, icon: "photo.fill", label: "Gallery")
|
||||
.background(tabWidthReader(.gallery))
|
||||
tabButton(.file, icon: "doc.fill", label: "File")
|
||||
.background(tabWidthReader(.file))
|
||||
tabButton(.avatar, icon: "person.crop.circle.fill", label: "Avatar")
|
||||
.background(tabWidthReader(.avatar))
|
||||
}
|
||||
.padding(4)
|
||||
.background { tabBarBackground }
|
||||
.coordinateSpace(name: "attachTabBar")
|
||||
.background(alignment: .leading) {
|
||||
// Sliding selection indicator (RosettaTabBar parity)
|
||||
attachmentSelectionIndicator
|
||||
}
|
||||
.background { TelegramGlassCapsule() }
|
||||
.clipShape(Capsule())
|
||||
.contentShape(Capsule())
|
||||
.tabBarShadow()
|
||||
}
|
||||
|
||||
/// Glass background matching RosettaTabBar (lines 136–149).
|
||||
private var tabBarBackground: some View {
|
||||
TelegramGlassCapsule()
|
||||
/// Reads tab width and origin for selection indicator positioning.
|
||||
private func tabWidthReader(_ tab: AttachmentTab) -> some View {
|
||||
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.
|
||||
@@ -345,21 +457,9 @@ struct AttachmentPanelView: View {
|
||||
let isSelected = selectedTab == tab
|
||||
|
||||
return Button {
|
||||
if tab == .avatar {
|
||||
if hasAvatar {
|
||||
onSendAvatar()
|
||||
dismiss()
|
||||
} else {
|
||||
// No avatar set — offer to set one
|
||||
dismiss()
|
||||
onSetAvatar?()
|
||||
}
|
||||
return
|
||||
} else {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
withAnimation(.spring(response: 0.34, dampingFraction: 0.82)) {
|
||||
selectedTab = tab
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
VStack(spacing: 2) {
|
||||
Image(systemName: icon)
|
||||
@@ -372,11 +472,6 @@ struct AttachmentPanelView: View {
|
||||
.foregroundStyle(isSelected ? Color(hex: 0x008BFF) : .white)
|
||||
.frame(minWidth: 66, maxWidth: .infinity)
|
||||
.padding(.vertical, 6)
|
||||
.background {
|
||||
if isSelected {
|
||||
TelegramGlassCapsule()
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
@@ -457,6 +552,22 @@ private enum AttachmentTab: Hashable {
|
||||
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
|
||||
|
||||
/// Wrapper to make PHAsset usable with SwiftUI `.fullScreenCover(item:)`.
|
||||
|
||||
@@ -181,7 +181,7 @@ struct ChatDetailView: View {
|
||||
}
|
||||
.overlay { chatEdgeGradients }
|
||||
// FPS overlay — uncomment for performance testing:
|
||||
// .overlay { FPSOverlayView() }
|
||||
.overlay { FPSOverlayView() }
|
||||
.overlay(alignment: .bottom) {
|
||||
if !route.isSystemAccount {
|
||||
KeyboardPaddedView {
|
||||
@@ -466,10 +466,11 @@ private extension ChatDetailView {
|
||||
color: .white
|
||||
)
|
||||
.frame(width: 11, height: 20)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
.frame(width: 36, height: 36)
|
||||
.contentShape(Circle())
|
||||
.background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white) }
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Back")
|
||||
}
|
||||
|
||||
@@ -479,6 +480,7 @@ private extension ChatDetailView {
|
||||
.padding(.horizontal, 12)
|
||||
.frame(minWidth: 120)
|
||||
.frame(height: 44)
|
||||
.contentShape(Capsule())
|
||||
.background {
|
||||
glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: .white)
|
||||
}
|
||||
@@ -490,6 +492,7 @@ private extension ChatDetailView {
|
||||
Button { openProfile() } label: {
|
||||
ChatDetailToolbarAvatar(route: route, size: 35)
|
||||
.frame(width: 36, height: 36)
|
||||
.contentShape(Circle())
|
||||
.background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white) }
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -508,6 +511,7 @@ private extension ChatDetailView {
|
||||
.padding(.horizontal, 16)
|
||||
.frame(minWidth: 120)
|
||||
.frame(height: 44)
|
||||
.contentShape(Capsule())
|
||||
.background {
|
||||
glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: .white)
|
||||
}
|
||||
@@ -519,6 +523,7 @@ private extension ChatDetailView {
|
||||
Button { openProfile() } label: {
|
||||
ChatDetailToolbarAvatar(route: route, size: 38)
|
||||
.frame(width: 44, height: 44)
|
||||
.contentShape(Circle())
|
||||
.background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white) }
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -533,10 +538,10 @@ private extension ChatDetailView {
|
||||
color: .white
|
||||
)
|
||||
.frame(width: 11, height: 20)
|
||||
.allowsHitTesting(false)
|
||||
.frame(width: 36, height: 36)
|
||||
.frame(height: 44)
|
||||
.padding(.horizontal, 4)
|
||||
.contentShape(Capsule())
|
||||
.background {
|
||||
glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: .white)
|
||||
}
|
||||
@@ -790,9 +795,8 @@ private extension ChatDetailView {
|
||||
scroll
|
||||
.scrollIndicators(.hidden)
|
||||
.overlay(alignment: .bottom) {
|
||||
KeyboardPaddedView(extraPadding: composerHeight + 4) {
|
||||
scrollToBottomButton(proxy: proxy)
|
||||
}
|
||||
.padding(.bottom, composerHeight + 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1766,6 +1770,13 @@ private extension ChatDetailView {
|
||||
func openProfile() {
|
||||
guard !route.isSavedMessages, !route.isSystemAccount else { return }
|
||||
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
|
||||
}
|
||||
|
||||
@@ -2109,17 +2120,31 @@ private extension ChatDetailView {
|
||||
func replyBar(for message: ChatMessage) -> some View {
|
||||
let senderName = senderDisplayName(for: message.fromPublicKey)
|
||||
let previewText: String = {
|
||||
let trimmed = message.text.trimmingCharacters(in: .whitespaces)
|
||||
if !trimmed.isEmpty { return message.text }
|
||||
if message.attachments.contains(where: { $0.type == .image }) { return "Photo" }
|
||||
// Attachment type labels — check BEFORE text so photo/avatar messages
|
||||
// always show their type even if text contains invisible characters.
|
||||
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 }) {
|
||||
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
|
||||
}
|
||||
if message.attachments.contains(where: { $0.type == .avatar }) { return "Avatar" }
|
||||
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" }
|
||||
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) {
|
||||
RoundedRectangle(cornerRadius: 1.5)
|
||||
|
||||
@@ -37,24 +37,15 @@ struct FullScreenImageViewer: View {
|
||||
.opacity(backgroundOpacity)
|
||||
.ignoresSafeArea()
|
||||
|
||||
// Zoomable image
|
||||
// Zoomable image (visual only — no gestures here)
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.scaleEffect(scale)
|
||||
.offset(x: offset.width, y: offset.height + dismissOffset)
|
||||
.gesture(dragGesture)
|
||||
.gesture(pinchGesture)
|
||||
.onTapGesture(count: 2) {
|
||||
doubleTap()
|
||||
}
|
||||
.onTapGesture(count: 1) {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
showControls.toggle()
|
||||
}
|
||||
}
|
||||
.allowsHitTesting(false)
|
||||
|
||||
// Close button
|
||||
// Close button (above gesture layer so it stays tappable)
|
||||
if showControls {
|
||||
VStack {
|
||||
HStack {
|
||||
@@ -77,6 +68,20 @@ struct FullScreenImageViewer: View {
|
||||
.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
|
||||
|
||||
@@ -134,7 +134,11 @@ struct ImageGalleryViewer: View {
|
||||
}
|
||||
}
|
||||
.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)
|
||||
|
||||
// Controls overlay
|
||||
|
||||
@@ -199,6 +199,17 @@ struct MessageAvatarView: View {
|
||||
if let cached = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) {
|
||||
avatarImage = cached
|
||||
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)
|
||||
.offset(x: effectiveScale > 1.05 ? zoomOffset.width : 0,
|
||||
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
|
||||
.onTapGesture { location in
|
||||
let width = UIScreen.main.bounds.width
|
||||
@@ -47,20 +64,8 @@ struct ZoomableImagePage: View {
|
||||
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
|
||||
.gesture(
|
||||
.simultaneousGesture(
|
||||
MagnifyGesture()
|
||||
.updating($pinchScale) { value, state, _ in
|
||||
state = value.magnification
|
||||
@@ -78,7 +83,7 @@ struct ZoomableImagePage: View {
|
||||
}
|
||||
)
|
||||
// Pan when zoomed
|
||||
.gesture(
|
||||
.simultaneousGesture(
|
||||
zoomScale > 1.05 ?
|
||||
DragGesture()
|
||||
.onChanged { value in
|
||||
@@ -96,7 +101,6 @@ struct ZoomableImagePage: View {
|
||||
.simultaneousGesture(
|
||||
zoomScale <= 1.05 ? dismissDragGesture : nil
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
} else {
|
||||
placeholder
|
||||
}
|
||||
|
||||
@@ -106,6 +106,21 @@ struct ChatListView: View {
|
||||
guard let route = notification.object as? ChatRoute else { return }
|
||||
// Navigate to the chat from push notification tap
|
||||
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.
|
||||
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)
|
||||
|
||||
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 FirebaseCrashlytics
|
||||
import FirebaseMessaging
|
||||
import Intents
|
||||
import SwiftUI
|
||||
import UserNotifications
|
||||
|
||||
@@ -9,6 +10,11 @@ import UserNotifications
|
||||
final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate,
|
||||
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(
|
||||
_ application: UIApplication,
|
||||
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.
|
||||
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(
|
||||
identifier: "msg_\(senderKey)_\(Int(now))",
|
||||
content: content,
|
||||
content: finalContent,
|
||||
trigger: nil
|
||||
)
|
||||
UNUserNotificationCenter.current().add(request) { _ in
|
||||
@@ -212,6 +249,10 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
|
||||
username: "",
|
||||
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(
|
||||
name: .openChatFromNotification,
|
||||
object: route
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import UserNotifications
|
||||
import Intents
|
||||
|
||||
/// Notification Service Extension — runs as a separate process even when the main app
|
||||
/// 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
|
||||
/// 3. Normalizes sender_public_key in userInfo (Android parity: multi-key fallback)
|
||||
/// 4. Filters muted chats
|
||||
/// 5. Creates Communication Notification via INSendMessageIntent (CarPlay + Focus parity)
|
||||
final class NotificationService: UNNotificationServiceExtension {
|
||||
|
||||
private static let appGroupID = "group.com.rosetta.dev"
|
||||
@@ -76,7 +78,18 @@ final class NotificationService: UNNotificationServiceExtension {
|
||||
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() {
|
||||
@@ -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
|
||||
|
||||
/// Android parity: extract sender key from multiple possible key names.
|
||||
|
||||
Reference in New Issue
Block a user