Фикс: поднятие контента сообщений синхронно с раскрытием клавиатуры
This commit is contained in:
@@ -421,7 +421,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 24;
|
CURRENT_PROJECT_VERSION = 25;
|
||||||
DEVELOPMENT_TEAM = QN8Z263QGX;
|
DEVELOPMENT_TEAM = QN8Z263QGX;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -437,7 +437,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.2.3;
|
MARKETING_VERSION = 1.2.4;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
|
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
@@ -460,7 +460,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 24;
|
CURRENT_PROJECT_VERSION = 25;
|
||||||
DEVELOPMENT_TEAM = QN8Z263QGX;
|
DEVELOPMENT_TEAM = QN8Z263QGX;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -476,7 +476,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.2.3;
|
MARKETING_VERSION = 1.2.4;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
|
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
|
|||||||
@@ -1,6 +1,38 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
|
import ImageIO
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
// MARK: - ImageLoadLimiter
|
||||||
|
|
||||||
|
/// Android parity: `ImageLoadSemaphore` in `AttachmentComponents.kt`.
|
||||||
|
/// Limits concurrent image disk I/O + decode operations to 3,
|
||||||
|
/// preventing scroll lag when collages (4–5 photos) appear simultaneously.
|
||||||
|
actor ImageLoadLimiter {
|
||||||
|
static let shared = ImageLoadLimiter()
|
||||||
|
private let maxConcurrent = 3
|
||||||
|
private var running = 0
|
||||||
|
private var waiters: [CheckedContinuation<Void, Never>] = []
|
||||||
|
|
||||||
|
func acquire() async {
|
||||||
|
if running < maxConcurrent {
|
||||||
|
running += 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await withCheckedContinuation { continuation in
|
||||||
|
waiters.append(continuation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func release() {
|
||||||
|
if let next = waiters.first {
|
||||||
|
waiters.removeFirst()
|
||||||
|
next.resume()
|
||||||
|
} else {
|
||||||
|
running -= 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - AttachmentCache
|
// MARK: - AttachmentCache
|
||||||
|
|
||||||
/// Local disk cache for downloaded/decrypted attachment images and files.
|
/// Local disk cache for downloaded/decrypted attachment images and files.
|
||||||
@@ -18,13 +50,28 @@ final class AttachmentCache: @unchecked Sendable {
|
|||||||
|
|
||||||
private let cacheDir: URL
|
private let cacheDir: URL
|
||||||
|
|
||||||
|
/// In-memory image cache — eliminates disk I/O + crypto on scroll-back.
|
||||||
|
/// Android parity: LruCache in AttachmentFileManager.kt.
|
||||||
|
/// Same pattern as AvatarRepository.cache (NSCache is thread-safe).
|
||||||
|
private let imageCache: NSCache<NSString, UIImage> = {
|
||||||
|
let cache = NSCache<NSString, UIImage>()
|
||||||
|
cache.countLimit = 100
|
||||||
|
cache.totalCostLimit = 80 * 1024 * 1024 // 80 MB — auto-evicts under memory pressure
|
||||||
|
return cache
|
||||||
|
}()
|
||||||
|
|
||||||
/// Private key for encrypting files at rest (Android parity).
|
/// Private key for encrypting files at rest (Android parity).
|
||||||
/// Set from SessionManager.startSession() after unlocking account.
|
/// Set from SessionManager.startSession() after unlocking account.
|
||||||
private let keyLock = NSLock()
|
private let keyLock = NSLock()
|
||||||
private var _privateKey: String?
|
private var _privateKey: String?
|
||||||
var privateKey: String? {
|
var privateKey: String? {
|
||||||
get { keyLock.lock(); defer { keyLock.unlock() }; return _privateKey }
|
get { keyLock.lock(); defer { keyLock.unlock() }; return _privateKey }
|
||||||
set { keyLock.lock(); _privateKey = newValue; keyLock.unlock() }
|
set {
|
||||||
|
keyLock.lock()
|
||||||
|
_privateKey = newValue
|
||||||
|
keyLock.unlock()
|
||||||
|
if newValue == nil { imageCache.removeAllObjects() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
@@ -33,11 +80,36 @@ final class AttachmentCache: @unchecked Sendable {
|
|||||||
try? FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true)
|
try? FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Downsampled Decode
|
||||||
|
|
||||||
|
/// Android parity: `BitmapFactory.Options.inSampleSize` with max 4096px.
|
||||||
|
/// Uses `CGImageSource` for memory-efficient downsampled decoding + EXIF orientation.
|
||||||
|
/// `kCGImageSourceShouldCacheImmediately` forces decode now (not lazily on first draw).
|
||||||
|
static func downsampledImage(from data: Data, maxPixelSize: Int = 4096) -> UIImage? {
|
||||||
|
guard let source = CGImageSourceCreateWithData(data as CFData, nil) else {
|
||||||
|
return UIImage(data: data)
|
||||||
|
}
|
||||||
|
let options: [CFString: Any] = [
|
||||||
|
kCGImageSourceCreateThumbnailFromImageAlways: true,
|
||||||
|
kCGImageSourceShouldCacheImmediately: true,
|
||||||
|
kCGImageSourceCreateThumbnailWithTransform: true,
|
||||||
|
kCGImageSourceThumbnailMaxPixelSize: maxPixelSize
|
||||||
|
]
|
||||||
|
guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else {
|
||||||
|
return UIImage(data: data)
|
||||||
|
}
|
||||||
|
return UIImage(cgImage: cgImage)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Images
|
// MARK: - Images
|
||||||
|
|
||||||
/// Saves a decoded image to cache, encrypted with private key (Android parity).
|
/// Saves a decoded image to cache, encrypted with private key (Android parity).
|
||||||
func saveImage(_ image: UIImage, forAttachmentId id: String) {
|
func saveImage(_ image: UIImage, forAttachmentId id: String) {
|
||||||
guard let data = image.jpegData(compressionQuality: 0.95) else { return }
|
guard let data = image.jpegData(compressionQuality: 0.95) else { return }
|
||||||
|
|
||||||
|
// Warm in-memory cache immediately — next loadImage() returns in O(1)
|
||||||
|
imageCache.setObject(image, forKey: id as NSString, cost: data.count)
|
||||||
|
|
||||||
let url = cacheDir.appendingPathComponent("img_\(id).enc")
|
let url = cacheDir.appendingPathComponent("img_\(id).enc")
|
||||||
|
|
||||||
if let key = privateKey,
|
if let key = privateKey,
|
||||||
@@ -53,6 +125,11 @@ final class AttachmentCache: @unchecked Sendable {
|
|||||||
|
|
||||||
/// Loads a cached image for an attachment ID, or `nil` if not cached.
|
/// Loads a cached image for an attachment ID, or `nil` if not cached.
|
||||||
func loadImage(forAttachmentId id: String) -> UIImage? {
|
func loadImage(forAttachmentId id: String) -> UIImage? {
|
||||||
|
// Fast path: in-memory cache hit — no disk I/O, no crypto
|
||||||
|
if let cached = imageCache.object(forKey: id as NSString) {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
// Try encrypted format first
|
// Try encrypted format first
|
||||||
let encUrl = cacheDir.appendingPathComponent("img_\(id).enc")
|
let encUrl = cacheDir.appendingPathComponent("img_\(id).enc")
|
||||||
if FileManager.default.fileExists(atPath: encUrl.path),
|
if FileManager.default.fileExists(atPath: encUrl.path),
|
||||||
@@ -60,7 +137,8 @@ final class AttachmentCache: @unchecked Sendable {
|
|||||||
let encString = String(data: fileData, encoding: .utf8),
|
let encString = String(data: fileData, encoding: .utf8),
|
||||||
let key = privateKey,
|
let key = privateKey,
|
||||||
let decrypted = try? CryptoManager.shared.decryptWithPassword(encString, password: key),
|
let decrypted = try? CryptoManager.shared.decryptWithPassword(encString, password: key),
|
||||||
let image = UIImage(data: decrypted) {
|
let image = Self.downsampledImage(from: decrypted) {
|
||||||
|
imageCache.setObject(image, forKey: id as NSString, cost: decrypted.count)
|
||||||
return image
|
return image
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,6 +212,7 @@ final class AttachmentCache: @unchecked Sendable {
|
|||||||
// MARK: - Cleanup
|
// MARK: - Cleanup
|
||||||
|
|
||||||
func clearAll() {
|
func clearAll() {
|
||||||
|
imageCache.removeAllObjects()
|
||||||
try? FileManager.default.removeItem(at: cacheDir)
|
try? FileManager.default.removeItem(at: cacheDir)
|
||||||
try? FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true)
|
try? FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ struct ChatTextInput: UIViewRepresentable {
|
|||||||
@Binding var isFocused: Bool
|
@Binding var isFocused: Bool
|
||||||
var onKeyboardHeightChange: (CGFloat) -> Void
|
var onKeyboardHeightChange: (CGFloat) -> Void
|
||||||
var onUserTextInsertion: () -> Void = {}
|
var onUserTextInsertion: () -> Void = {}
|
||||||
|
var onMultilineChange: (Bool) -> Void = { _ in }
|
||||||
var font: UIFont = .systemFont(ofSize: 17, weight: .regular)
|
var font: UIFont = .systemFont(ofSize: 17, weight: .regular)
|
||||||
var textColor: UIColor = .white
|
var textColor: UIColor = .white
|
||||||
var placeholderColor: UIColor = UIColor.white.withAlphaComponent(0.35)
|
var placeholderColor: UIColor = UIColor.white.withAlphaComponent(0.35)
|
||||||
@@ -195,6 +196,7 @@ struct ChatTextInput: UIViewRepresentable {
|
|||||||
final class Coordinator: NSObject, UITextViewDelegate {
|
final class Coordinator: NSObject, UITextViewDelegate {
|
||||||
var parent: ChatTextInput
|
var parent: ChatTextInput
|
||||||
var isUpdatingText = false
|
var isUpdatingText = false
|
||||||
|
private var wasMultiline = false
|
||||||
private var pendingFocusSync: DispatchWorkItem?
|
private var pendingFocusSync: DispatchWorkItem?
|
||||||
|
|
||||||
init(parent: ChatTextInput) {
|
init(parent: ChatTextInput) {
|
||||||
@@ -248,6 +250,17 @@ struct ChatTextInput: UIViewRepresentable {
|
|||||||
|
|
||||||
func invalidateHeight(_ tv: UITextView) {
|
func invalidateHeight(_ tv: UITextView) {
|
||||||
tv.invalidateIntrinsicContentSize()
|
tv.invalidateIntrinsicContentSize()
|
||||||
|
checkMultiline(tv)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func checkMultiline(_ tv: UITextView) {
|
||||||
|
let lineHeight = tv.font?.lineHeight ?? 20
|
||||||
|
let singleLineHeight = lineHeight + tv.textContainerInset.top + tv.textContainerInset.bottom
|
||||||
|
let isMultiline = tv.contentSize.height > singleLineHeight + 0.5
|
||||||
|
if isMultiline != wasMultiline {
|
||||||
|
wasMultiline = isMultiline
|
||||||
|
parent.onMultilineChange(isMultiline)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func syncFocus(for tv: UITextView) {
|
func syncFocus(for tv: UITextView) {
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ final class ComposerHostView: UIView {
|
|||||||
|
|
||||||
backgroundColor = .clear
|
backgroundColor = .clear
|
||||||
hostingController.view.backgroundColor = .clear
|
hostingController.view.backgroundColor = .clear
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
hostingController.sizingOptions = .intrinsicContentSize
|
||||||
|
}
|
||||||
if #available(iOS 16.4, *) {
|
if #available(iOS 16.4, *) {
|
||||||
hostingController.safeAreaRegions = []
|
hostingController.safeAreaRegions = []
|
||||||
}
|
}
|
||||||
@@ -78,6 +81,10 @@ final class ComposerHostView: UIView {
|
|||||||
|
|
||||||
func updateContent(_ content: AnyView) {
|
func updateContent(_ content: AnyView) {
|
||||||
hostingController.rootView = content
|
hostingController.rootView = content
|
||||||
|
// Force hosting controller to re-measure after content change —
|
||||||
|
// nested UIViewRepresentable (ChatTextInput) size changes may not
|
||||||
|
// propagate through the hosting controller automatically.
|
||||||
|
hostingController.view.invalidateIntrinsicContentSize()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Moves composer up by `offset` points from the bottom.
|
/// Moves composer up by `offset` points from the bottom.
|
||||||
|
|||||||
@@ -89,6 +89,12 @@ final class KeyboardTracker: ObservableObject {
|
|||||||
.publisher(for: UIResponder.keyboardWillChangeFrameNotification)
|
.publisher(for: UIResponder.keyboardWillChangeFrameNotification)
|
||||||
.sink { [weak self] in self?.handleNotification($0) }
|
.sink { [weak self] in self?.handleNotification($0) }
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
// Pre-create display link (paused) — avoids allocation overhead on first keyboard show.
|
||||||
|
displayLinkProxy = DisplayLinkProxy { [weak self] in
|
||||||
|
self?.animationTick()
|
||||||
|
}
|
||||||
|
displayLinkProxy?.isPaused = true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sets keyboardPadding with animation matching keyboard duration.
|
/// Sets keyboardPadding with animation matching keyboard duration.
|
||||||
@@ -146,8 +152,9 @@ final class KeyboardTracker: ObservableObject {
|
|||||||
if self.keyboardPadding != 0 {
|
if self.keyboardPadding != 0 {
|
||||||
self.keyboardPadding = 0
|
self.keyboardPadding = 0
|
||||||
}
|
}
|
||||||
// spacerPadding NOT reset here — only from handleNotification
|
if self.spacerPadding != 0 {
|
||||||
// with withAnimation to avoid jumps during rapid toggling.
|
self.spacerPadding = 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -166,6 +173,9 @@ final class KeyboardTracker: ObservableObject {
|
|||||||
let current = pendingKVOPadding ?? keyboardPadding
|
let current = pendingKVOPadding ?? keyboardPadding
|
||||||
guard newPadding < current else { return }
|
guard newPadding < current else { return }
|
||||||
|
|
||||||
|
// Drop spacerPadding floor so max() follows keyboardPadding during dismiss
|
||||||
|
if spacerPadding != 0 { spacerPadding = 0 }
|
||||||
|
|
||||||
// Move composer immediately (UIKit, no SwiftUI overhead)
|
// Move composer immediately (UIKit, no SwiftUI overhead)
|
||||||
composerHostView?.setKeyboardOffset(rawPadding)
|
composerHostView?.setKeyboardOffset(rawPadding)
|
||||||
|
|
||||||
@@ -235,6 +245,24 @@ final class KeyboardTracker: ObservableObject {
|
|||||||
let duration = info[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval ?? 0.25
|
let duration = info[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval ?? 0.25
|
||||||
let curveRaw = info[UIResponder.keyboardAnimationCurveUserInfoKey] as? Int ?? 0
|
let curveRaw = info[UIResponder.keyboardAnimationCurveUserInfoKey] as? Int ?? 0
|
||||||
|
|
||||||
|
// Configure bezier early — needed for curve-aware initial spacerPadding below.
|
||||||
|
configureBezier(curveRaw: curveRaw)
|
||||||
|
|
||||||
|
// Direction-dependent spacerPadding initialization:
|
||||||
|
let isShow = targetPadding > keyboardPadding
|
||||||
|
if isShow {
|
||||||
|
// Curve-aware head start: evaluate bezier at ~1 frame into animation.
|
||||||
|
// Covers the gap before first CADisplayLink tick fires (~8ms on 120Hz, ~16ms on 60Hz).
|
||||||
|
// Smooth (follows curve shape), not a jarring jump.
|
||||||
|
let initialT = min(0.016 / max(duration, 0.05), 1.0)
|
||||||
|
let initialEased = cubicBezierEase(initialT)
|
||||||
|
let initialValue = keyboardPadding + (targetPadding - keyboardPadding) * initialEased
|
||||||
|
spacerPadding = max(keyboardPadding, round(initialValue))
|
||||||
|
} else {
|
||||||
|
// HIDE: instant drop, keyboardPadding drives smooth descent
|
||||||
|
spacerPadding = 0
|
||||||
|
}
|
||||||
|
|
||||||
let delta = targetPadding - lastNotificationPadding
|
let delta = targetPadding - lastNotificationPadding
|
||||||
lastNotificationPadding = targetPadding
|
lastNotificationPadding = targetPadding
|
||||||
|
|
||||||
@@ -331,17 +359,16 @@ final class KeyboardTracker: ObservableObject {
|
|||||||
lastEased = 0
|
lastEased = 0
|
||||||
previousMonotonicEased = 0
|
previousMonotonicEased = 0
|
||||||
|
|
||||||
|
// Always configure bezier — needed for spacerPadding advance computation
|
||||||
|
// even when sync view is the primary easing source.
|
||||||
|
configureBezier(curveRaw: curveRaw)
|
||||||
|
|
||||||
// Primary: sync view matches keyboard's exact curve (same CA transaction).
|
// Primary: sync view matches keyboard's exact curve (same CA transaction).
|
||||||
let syncOK = setupSyncAnimation(duration: duration, curveRaw: curveRaw)
|
let syncOK = setupSyncAnimation(duration: duration, curveRaw: curveRaw)
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
print("⌨️ 🎬 START | from=\(Int(keyboardPadding)) to=\(Int(target)) syncOK=\(syncOK) dur=\(String(format: "%.3f", duration))s")
|
print("⌨️ 🎬 START | from=\(Int(keyboardPadding)) to=\(Int(target)) syncOK=\(syncOK) dur=\(String(format: "%.3f", duration))s")
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// 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.
|
// Reuse existing display link to preserve vsync phase alignment.
|
||||||
if let proxy = displayLinkProxy {
|
if let proxy = displayLinkProxy {
|
||||||
proxy.isPaused = false
|
proxy.isPaused = false
|
||||||
@@ -423,7 +450,7 @@ final class KeyboardTracker: ObservableObject {
|
|||||||
if animTickCount > 1 {
|
if animTickCount > 1 {
|
||||||
let velocity = eased - previousMonotonicEased
|
let velocity = eased - previousMonotonicEased
|
||||||
if velocity > 0 {
|
if velocity > 0 {
|
||||||
eased = min(eased + velocity, 1.0)
|
eased = min(eased + velocity * 1.0, 1.0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
previousMonotonicEased = rawEased
|
previousMonotonicEased = rawEased
|
||||||
@@ -452,6 +479,7 @@ final class KeyboardTracker: ObservableObject {
|
|||||||
if hardDeadline || animTickCount > 40 || (pastDuration && closeEnough) {
|
if hardDeadline || animTickCount > 40 || (pastDuration && closeEnough) {
|
||||||
let prevPadding = keyboardPadding
|
let prevPadding = keyboardPadding
|
||||||
keyboardPadding = max(0, animTargetPadding)
|
keyboardPadding = max(0, animTargetPadding)
|
||||||
|
spacerPadding = 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
|
||||||
@@ -473,6 +501,23 @@ final class KeyboardTracker: ObservableObject {
|
|||||||
print("⌨️ T\(animTickCount) | syncOp=\(syncOpacity.map { String(format: "%.3f", $0) } ?? "nil") eased=\(String(format: "%.4f", eased)) | pad \(Int(prevPad))→\(Int(rounded)) Δ\(Int(rounded - prevPad))pt | \(String(format: "%.1f", elapsed * 1000))ms\(gliding ? " 🛬" : "")")
|
print("⌨️ T\(animTickCount) | syncOp=\(syncOpacity.map { String(format: "%.3f", $0) } ?? "nil") eased=\(String(format: "%.4f", eased)) | pad \(Int(prevPad))→\(Int(rounded)) Δ\(Int(rounded - prevPad))pt | \(String(format: "%.1f", elapsed * 1000))ms\(gliding ? " 🛬" : "")")
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Time-advanced spacerPadding: leads keyboardPadding by ~1 frame.
|
||||||
|
// Prevents compositor-message overlap during SHOW — UIKit composer has
|
||||||
|
// zero lag (same CA transaction), but SwiftUI spacer has 15-25ms render
|
||||||
|
// delay. Without this advance, messages lag behind the rising composer.
|
||||||
|
if animTargetPadding > animStartPadding {
|
||||||
|
let advanceMs: CFTimeInterval = 0.016
|
||||||
|
let advancedT = min((elapsed + advanceMs) / animDuration, 1.0)
|
||||||
|
let advancedEased = cubicBezierEase(advancedT)
|
||||||
|
let advancedRaw = animStartPadding + (animTargetPadding - animStartPadding) * advancedEased
|
||||||
|
let advancedRounded = max(0, round(advancedRaw))
|
||||||
|
// Monotonic: only increase during SHOW (no jitter from bezier approximation)
|
||||||
|
if advancedRounded > spacerPadding {
|
||||||
|
spacerPadding = advancedRounded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Cubic bezier fallback
|
// MARK: - Cubic bezier fallback
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ private struct KeyboardSpacer: View {
|
|||||||
// Inverted scroll: spacer at VStack START. Growing it pushes
|
// Inverted scroll: spacer at VStack START. Growing it pushes
|
||||||
// messages away from offset=0 → visually UP. CADisplayLink
|
// messages away from offset=0 → visually UP. CADisplayLink
|
||||||
// animates keyboardPadding in sync with keyboard curve.
|
// animates keyboardPadding in sync with keyboard curve.
|
||||||
return composerHeight + keyboard.keyboardPadding + 4
|
return composerHeight + max(keyboard.keyboardPadding, keyboard.spacerPadding) + 8
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
@@ -88,6 +88,7 @@ struct ChatDetailView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@State private var messageText = ""
|
@State private var messageText = ""
|
||||||
|
@State private var isMultilineInput = false
|
||||||
@State private var sendError: String?
|
@State private var sendError: String?
|
||||||
@State private var isViewActive = false
|
@State private var isViewActive = false
|
||||||
// markReadTask removed — read receipts no longer sent from .onChange(of: messages.count)
|
// markReadTask removed — read receipts no longer sent from .onChange(of: messages.count)
|
||||||
@@ -1598,6 +1599,11 @@ private extension ChatDetailView {
|
|||||||
KeyboardTracker.shared.updateFromKVO(keyboardHeight: height)
|
KeyboardTracker.shared.updateFromKVO(keyboardHeight: height)
|
||||||
},
|
},
|
||||||
onUserTextInsertion: handleComposerUserTyping,
|
onUserTextInsertion: handleComposerUserTyping,
|
||||||
|
onMultilineChange: { multiline in
|
||||||
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
|
isMultilineInput = multiline
|
||||||
|
}
|
||||||
|
},
|
||||||
textColor: UIColor(RosettaColors.Adaptive.text),
|
textColor: UIColor(RosettaColors.Adaptive.text),
|
||||||
placeholderColor: UIColor(RosettaColors.Adaptive.textSecondary.opacity(0.5))
|
placeholderColor: UIColor(RosettaColors.Adaptive.textSecondary.opacity(0.5))
|
||||||
)
|
)
|
||||||
@@ -1652,7 +1658,7 @@ private extension ChatDetailView {
|
|||||||
}
|
}
|
||||||
.padding(3)
|
.padding(3)
|
||||||
.frame(minHeight: 42, alignment: .bottom)
|
.frame(minHeight: 42, alignment: .bottom)
|
||||||
.background { glass(shape: .rounded(21), strokeOpacity: 0.18) }
|
.background { glass(shape: .rounded(isMultilineInput ? 16 : 21), strokeOpacity: 0.18) }
|
||||||
.padding(.leading, 6)
|
.padding(.leading, 6)
|
||||||
|
|
||||||
Button(action: trailingAction) {
|
Button(action: trailingAction) {
|
||||||
|
|||||||
@@ -92,9 +92,9 @@ struct MessageAvatarView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.task {
|
.task {
|
||||||
loadFromCache()
|
await loadFromCache()
|
||||||
if avatarImage == nil {
|
if avatarImage == nil {
|
||||||
decodeBlurHash()
|
await decodeBlurHash()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -169,14 +169,19 @@ struct MessageAvatarView: View {
|
|||||||
/// Shared static cache with half-eviction (same pattern as MessageImageView).
|
/// Shared static cache with half-eviction (same pattern as MessageImageView).
|
||||||
@MainActor private static var blurHashCache: [String: UIImage] = [:]
|
@MainActor private static var blurHashCache: [String: UIImage] = [:]
|
||||||
|
|
||||||
private func decodeBlurHash() {
|
private func decodeBlurHash() async {
|
||||||
let hash = extractBlurHash(from: attachment.preview)
|
let hash = extractBlurHash(from: attachment.preview)
|
||||||
guard !hash.isEmpty else { return }
|
guard !hash.isEmpty else { return }
|
||||||
|
// Fast path: cache hit (synchronous)
|
||||||
if let cached = Self.blurHashCache[hash] {
|
if let cached = Self.blurHashCache[hash] {
|
||||||
blurImage = cached
|
blurImage = cached
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if let result = UIImage.fromBlurHash(hash, width: 32, height: 32) {
|
// Slow path: DCT decode off main thread
|
||||||
|
let result = await Task.detached(priority: .userInitiated) {
|
||||||
|
UIImage.fromBlurHash(hash, width: 32, height: 32)
|
||||||
|
}.value
|
||||||
|
guard !Task.isCancelled, let result else { return }
|
||||||
if Self.blurHashCache.count > 200 {
|
if Self.blurHashCache.count > 200 {
|
||||||
let keysToRemove = Array(Self.blurHashCache.keys.prefix(100))
|
let keysToRemove = Array(Self.blurHashCache.keys.prefix(100))
|
||||||
for key in keysToRemove { Self.blurHashCache.removeValue(forKey: key) }
|
for key in keysToRemove { Self.blurHashCache.removeValue(forKey: key) }
|
||||||
@@ -184,7 +189,6 @@ struct MessageAvatarView: View {
|
|||||||
Self.blurHashCache[hash] = result
|
Self.blurHashCache[hash] = result
|
||||||
blurImage = result
|
blurImage = result
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// Extracts the blurhash from preview string.
|
/// Extracts the blurhash from preview string.
|
||||||
/// Format: "tag::blurhash" → returns "blurhash".
|
/// Format: "tag::blurhash" → returns "blurhash".
|
||||||
@@ -195,12 +199,26 @@ struct MessageAvatarView: View {
|
|||||||
|
|
||||||
// MARK: - Download
|
// MARK: - Download
|
||||||
|
|
||||||
private func loadFromCache() {
|
private func loadFromCache() async {
|
||||||
|
// Fast path: NSCache hit (synchronous, sub-microsecond)
|
||||||
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
|
return
|
||||||
}
|
}
|
||||||
|
// Slow path: disk I/O + crypto — run off main thread with semaphore
|
||||||
|
// Android parity: ImageLoadSemaphore limits to 3 concurrent decode ops
|
||||||
|
let attachmentId = attachment.id
|
||||||
|
await ImageLoadLimiter.shared.acquire()
|
||||||
|
let loaded = await Task.detached(priority: .userInitiated) {
|
||||||
|
AttachmentCache.shared.loadImage(forAttachmentId: attachmentId)
|
||||||
|
}.value
|
||||||
|
await ImageLoadLimiter.shared.release()
|
||||||
|
if !Task.isCancelled, let loaded {
|
||||||
|
avatarImage = loaded
|
||||||
|
showAvatar = true
|
||||||
|
return
|
||||||
|
}
|
||||||
// Outgoing avatar: sender is me — load from AvatarRepository (always available locally)
|
// Outgoing avatar: sender is me — load from AvatarRepository (always available locally)
|
||||||
if outgoing {
|
if outgoing {
|
||||||
let myKey = SessionManager.shared.currentPublicKey
|
let myKey = SessionManager.shared.currentPublicKey
|
||||||
@@ -283,22 +301,22 @@ struct MessageAvatarView: View {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parses decrypted data as an image: data URI, plain base64, or raw image bytes.
|
/// Android parity: `base64ToBitmap()` with subsampling to max 4096px.
|
||||||
private func parseImageData(_ data: Data) -> UIImage? {
|
private func parseImageData(_ data: Data) -> UIImage? {
|
||||||
if let str = String(data: data, encoding: .utf8) {
|
if let str = String(data: data, encoding: .utf8) {
|
||||||
if str.hasPrefix("data:"),
|
if str.hasPrefix("data:"),
|
||||||
let commaIndex = str.firstIndex(of: ",") {
|
let commaIndex = str.firstIndex(of: ",") {
|
||||||
let base64Part = String(str[str.index(after: commaIndex)...])
|
let base64Part = String(str[str.index(after: commaIndex)...])
|
||||||
if let imageData = Data(base64Encoded: base64Part),
|
if let imageData = Data(base64Encoded: base64Part),
|
||||||
let img = UIImage(data: imageData) {
|
let img = AttachmentCache.downsampledImage(from: imageData) {
|
||||||
return img
|
return img
|
||||||
}
|
}
|
||||||
} else if let imageData = Data(base64Encoded: str),
|
} else if let imageData = Data(base64Encoded: str),
|
||||||
let img = UIImage(data: imageData) {
|
let img = AttachmentCache.downsampledImage(from: imageData) {
|
||||||
return img
|
return img
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return UIImage(data: data)
|
return AttachmentCache.downsampledImage(from: data)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extracts the server tag from preview string.
|
/// Extracts the server tag from preview string.
|
||||||
|
|||||||
@@ -66,9 +66,9 @@ struct MessageImageView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.task {
|
.task {
|
||||||
loadFromCache()
|
await loadFromCache()
|
||||||
if image == nil {
|
if image == nil {
|
||||||
decodeBlurHash()
|
await decodeBlurHash()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onReceive(NotificationCenter.default.publisher(for: .triggerAttachmentDownload)) { notif in
|
.onReceive(NotificationCenter.default.publisher(for: .triggerAttachmentDownload)) { notif in
|
||||||
@@ -205,14 +205,19 @@ struct MessageImageView: View {
|
|||||||
|
|
||||||
@MainActor private static var blurHashCache: [String: UIImage] = [:]
|
@MainActor private static var blurHashCache: [String: UIImage] = [:]
|
||||||
|
|
||||||
private func decodeBlurHash() {
|
private func decodeBlurHash() async {
|
||||||
let hash = extractBlurHash(from: attachment.preview)
|
let hash = extractBlurHash(from: attachment.preview)
|
||||||
guard !hash.isEmpty else { return }
|
guard !hash.isEmpty else { return }
|
||||||
|
// Fast path: cache hit (synchronous)
|
||||||
if let cached = Self.blurHashCache[hash] {
|
if let cached = Self.blurHashCache[hash] {
|
||||||
blurImage = cached
|
blurImage = cached
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if let result = UIImage.fromBlurHash(hash, width: 32, height: 32) {
|
// Slow path: DCT decode off main thread
|
||||||
|
let result = await Task.detached(priority: .userInitiated) {
|
||||||
|
UIImage.fromBlurHash(hash, width: 32, height: 32)
|
||||||
|
}.value
|
||||||
|
guard !Task.isCancelled, let result else { return }
|
||||||
if Self.blurHashCache.count > 200 {
|
if Self.blurHashCache.count > 200 {
|
||||||
let keysToRemove = Array(Self.blurHashCache.keys.prefix(100))
|
let keysToRemove = Array(Self.blurHashCache.keys.prefix(100))
|
||||||
for key in keysToRemove { Self.blurHashCache.removeValue(forKey: key) }
|
for key in keysToRemove { Self.blurHashCache.removeValue(forKey: key) }
|
||||||
@@ -220,14 +225,26 @@ struct MessageImageView: View {
|
|||||||
Self.blurHashCache[hash] = result
|
Self.blurHashCache[hash] = result
|
||||||
blurImage = result
|
blurImage = result
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Download
|
// MARK: - Download
|
||||||
|
|
||||||
private func loadFromCache() {
|
private func loadFromCache() async {
|
||||||
PerformanceLogger.shared.track("image.cacheLoad")
|
PerformanceLogger.shared.track("image.cacheLoad")
|
||||||
|
// Fast path: NSCache hit (synchronous, sub-microsecond)
|
||||||
if let cached = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) {
|
if let cached = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) {
|
||||||
image = cached
|
image = cached
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Slow path: disk I/O + crypto — run off main thread with semaphore
|
||||||
|
// Android parity: ImageLoadSemaphore limits to 3 concurrent decode ops
|
||||||
|
let attachmentId = attachment.id
|
||||||
|
await ImageLoadLimiter.shared.acquire()
|
||||||
|
let loaded = await Task.detached(priority: .userInitiated) {
|
||||||
|
AttachmentCache.shared.loadImage(forAttachmentId: attachmentId)
|
||||||
|
}.value
|
||||||
|
await ImageLoadLimiter.shared.release()
|
||||||
|
if !Task.isCancelled, let loaded {
|
||||||
|
image = loaded
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,21 +310,22 @@ struct MessageImageView: View {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Android parity: `base64ToBitmap()` with subsampling to max 4096px.
|
||||||
private func parseImageData(_ data: Data) -> UIImage? {
|
private func parseImageData(_ data: Data) -> UIImage? {
|
||||||
if let str = String(data: data, encoding: .utf8) {
|
if let str = String(data: data, encoding: .utf8) {
|
||||||
if str.hasPrefix("data:"),
|
if str.hasPrefix("data:"),
|
||||||
let commaIndex = str.firstIndex(of: ",") {
|
let commaIndex = str.firstIndex(of: ",") {
|
||||||
let base64Part = String(str[str.index(after: commaIndex)...])
|
let base64Part = String(str[str.index(after: commaIndex)...])
|
||||||
if let imageData = Data(base64Encoded: base64Part),
|
if let imageData = Data(base64Encoded: base64Part),
|
||||||
let img = UIImage(data: imageData) {
|
let img = AttachmentCache.downsampledImage(from: imageData) {
|
||||||
return img
|
return img
|
||||||
}
|
}
|
||||||
} else if let imageData = Data(base64Encoded: str),
|
} else if let imageData = Data(base64Encoded: str),
|
||||||
let img = UIImage(data: imageData) {
|
let img = AttachmentCache.downsampledImage(from: imageData) {
|
||||||
return img
|
return img
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return UIImage(data: data)
|
return AttachmentCache.downsampledImage(from: data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Preview Parsing
|
// MARK: - Preview Parsing
|
||||||
|
|||||||
Reference in New Issue
Block a user