Оптимизация FPS чата: ForEach fast path, keyboard animation без updateUIView, debounce pipeline, кэши с half-eviction, release notes механизм

This commit is contained in:
2026-03-19 03:35:04 +05:00
parent 422b20702e
commit 44652e0d97
21 changed files with 1349 additions and 318 deletions

View File

@@ -52,8 +52,6 @@
/* Begin PBXFileSystemSynchronizedRootGroup section */
853F29642F4B50410092AD05 /* Rosetta */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = Rosetta;
sourceTree = "<group>";
};
@@ -124,7 +122,6 @@
0F43A41D5496A62870E307FC /* NotificationService.swift */,
93685A4F330DCD1B63EF121F /* Info.plist */,
);
name = RosettaNotificationService;
path = RosettaNotificationService;
sourceTree = "<group>";
};
@@ -420,7 +417,7 @@
CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 19;
CURRENT_PROJECT_VERSION = 20;
DEVELOPMENT_TEAM = QN8Z263QGX;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -436,7 +433,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.1.8;
MARKETING_VERSION = 1.1.9;
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -459,7 +456,7 @@
CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 19;
CURRENT_PROJECT_VERSION = 20;
DEVELOPMENT_TEAM = QN8Z263QGX;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -475,7 +472,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.1.8;
MARKETING_VERSION = 1.1.9;
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -529,7 +526,7 @@
853F296C2F4B50420092AD05 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
defaultConfigurationName = Debug;
};
853F296D2F4B50420092AD05 /* Build configuration list for PBXNativeTarget "Rosetta" */ = {
isa = XCConfigurationList;
@@ -538,7 +535,7 @@
853F296F2F4B50420092AD05 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
defaultConfigurationName = Debug;
};
B5D2E60ADEB8AE2E8F7615C6 /* Build configuration list for PBXNativeTarget "RosettaNotificationService" */ = {
isa = XCConfigurationList;
@@ -547,7 +544,7 @@
0140D6320A9CF4B5E933E0B1 /* Debug */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
defaultConfigurationName = Debug;
};
/* End XCConfigurationList section */

View File

@@ -31,7 +31,7 @@
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Release"
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
@@ -50,6 +50,19 @@
ReferencedContainer = "container:Rosetta.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
<CommandLineArgument
argument = "-LogForEachSlowPath YES"
isEnabled = "YES">
</CommandLineArgument>
</CommandLineArguments>
<EnvironmentVariables>
<EnvironmentVariable
key = "CA_DEBUG_TRANSACTIONS"
value = "1"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"

View File

@@ -34,6 +34,7 @@ final class AttachmentCache: @unchecked Sendable {
}
/// Loads a cached image for an attachment ID, or `nil` if not cached.
/// Uses simple UIImage loading downsampling moved to background in MessageImageView.
func loadImage(forAttachmentId id: String) -> UIImage? {
let url = cacheDir.appendingPathComponent("img_\(id).jpg")
guard FileManager.default.fileExists(atPath: url.path) else { return nil }

View File

@@ -247,6 +247,7 @@ final class DialogRepository {
}
func updateOnlineState(publicKey: String, isOnline: Bool) {
PerformanceLogger.shared.track("dialog.updateOnline")
guard var dialog = dialogs[publicKey] else { return }
guard dialog.isOnline != isOnline else { return }
dialog.isOnline = isOnline
@@ -279,6 +280,7 @@ final class DialogRepository {
}
func updateUserInfo(publicKey: String, title: String, username: String, verified: Int = 0, online: Int = -1) {
PerformanceLogger.shared.track("dialog.updateUserInfo")
guard var dialog = dialogs[publicKey] else { return }
var changed = false
if !title.isEmpty, dialog.opponentTitle != title {
@@ -484,8 +486,9 @@ final class DialogRepository {
private func schedulePersist() {
guard !currentAccount.isEmpty else { return }
PerformanceLogger.shared.track("dialog.schedulePersist")
updateAppBadge()
scheduleAppBadgeUpdate()
let snapshot = Array(dialogs.values)
let fileName = Self.dialogsFileName(for: currentAccount)
@@ -502,11 +505,29 @@ final class DialogRepository {
}
}
/// PERF: debounced badge update avoids filter+reduce of 100+ dialogs + system API calls
/// on every single dialog mutation. Coalesces rapid updates into one.
private var badgeUpdateTask: Task<Void, Never>?
private var lastBadgeTotal: Int = -1
private func scheduleAppBadgeUpdate() {
badgeUpdateTask?.cancel()
badgeUpdateTask = Task { @MainActor [weak self] in
try? await Task.sleep(for: .milliseconds(500))
guard !Task.isCancelled, let self else { return }
self.updateAppBadge()
}
}
/// Update app icon badge with total unread message count.
/// Writes to shared App Group UserDefaults so the Notification Service Extension
/// can read the current count and increment it when the app is terminated.
private func updateAppBadge() {
let total = dialogs.values.filter { !$0.isMuted }.reduce(0) { $0 + $1.unreadCount }
PerformanceLogger.shared.track("dialog.badgeUpdate")
let total = dialogs.values.reduce(0) { $0 + ($1.isMuted ? 0 : $1.unreadCount) }
// Guard: skip if badge hasn't changed (avoids system API + UserDefaults writes).
guard total != lastBadgeTotal else { return }
lastBadgeTotal = total
UNUserNotificationCenter.current().setBadgeCount(total)
// Shared storage NSE reads this to increment badge when app is killed.
let shared = UserDefaults(suiteName: "group.com.rosetta.dev")

View File

@@ -141,6 +141,7 @@ final class MessageRepository: ObservableObject {
attachmentPassword: String? = nil,
fromSync: Bool = false
) {
PerformanceLogger.shared.track("message.upsert")
let fromMe = packet.fromPublicKey == myPublicKey
let dialogKey = fromMe ? packet.toPublicKey : packet.fromPublicKey
let messageId = packet.messageId.isEmpty ? UUID().uuidString : packet.messageId
@@ -205,6 +206,7 @@ final class MessageRepository: ObservableObject {
}
func updateDeliveryStatus(messageId: String, status: DeliveryStatus, newTimestamp: Int64? = nil) {
PerformanceLogger.shared.track("message.deliveryUpdate")
guard let dialogKey = messageToDialog[messageId] else { return }
updateMessages(for: dialogKey) { messages in
guard let index = messages.firstIndex(where: { $0.id == messageId }) else { return }
@@ -365,6 +367,15 @@ final class MessageRepository: ObservableObject {
}
}
// MARK: - Stress Test (Debug only)
/// Inserts a pre-built message for stress testing. Skips encryption/dedup.
func insertStressTestMessage(_ message: ChatMessage, dialogKey: String) {
updateMessages(for: dialogKey) { messages in
messages.append(message)
}
}
// MARK: - Private
private func updateMessages(for dialogKey: String, mutate: (inout [ChatMessage]) -> Void) {
@@ -400,6 +411,7 @@ final class MessageRepository: ObservableObject {
private func schedulePersist() {
guard !currentAccount.isEmpty else { return }
PerformanceLogger.shared.track("message.schedulePersist")
let snapshot = messagesByDialog
let idsSnapshot = allKnownMessageIds
@@ -407,9 +419,11 @@ final class MessageRepository: ObservableObject {
let knownIdsFile = Self.knownIdsFileName(for: currentAccount)
let storagePassword = self.storagePassword
let password = storagePassword.isEmpty ? nil : storagePassword
// During sync bursts, increase debounce to reduce disk I/O (full dict serialization).
// PERF: increased debounce to reduce JSON serialization frequency.
// Android batches DB writes via Room transactions; iOS must debounce manually.
// 800ms normal = ~1.25 saves/sec; 3000ms sync = ~0.33 saves/sec.
let isSyncing = SessionManager.shared.syncBatchInProgress
let debounceMs = isSyncing ? 2000 : 400
let debounceMs = isSyncing ? 3000 : 800
persistTask?.cancel()
persistTask = Task(priority: .utility) {
try? await Task.sleep(for: .milliseconds(debounceMs))

View File

@@ -148,6 +148,7 @@ final class ProtocolManager: @unchecked Sendable {
// MARK: - Sending
func sendPacket(_ packet: any Packet) {
PerformanceLogger.shared.track("protocol.sendPacket")
let id = String(type(of: packet).packetId, radix: 16)
if (!handshakeComplete && !(packet is PacketHandshake)) || !client.isConnected {
Self.logger.info("⏳ Queueing packet 0x\(id) — connected=\(self.client.isConnected), handshake=\(self.handshakeComplete)")

View File

@@ -122,6 +122,7 @@ final class SessionManager {
/// Sends an encrypted message to a recipient, matching Android's outgoing flow.
func sendMessage(text: String, toPublicKey: String, opponentTitle: String = "", opponentUsername: String = "") async throws {
PerformanceLogger.shared.track("session.sendMessage")
// Desktop parity: validate message is not empty/whitespace-only before sending.
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
@@ -1122,6 +1123,7 @@ final class SessionManager {
}
private func processIncomingMessage(_ packet: PacketMessage) async {
PerformanceLogger.shared.track("session.processIncoming")
let myKey = currentPublicKey
let currentPrivateKeyHex = self.privateKeyHex
let currentPrivateKeyHash = self.privateKeyHash
@@ -1534,6 +1536,9 @@ final class SessionManager {
/// Request user info for all existing dialog opponents after sync completes.
/// Desktop parity: useUserInformation sends PacketSearch(publicKey) lazily per-component.
/// We do it in bulk after sync with generous staggering to avoid server rate-limiting.
/// PERF: cap at 30 dialogs to avoid sending 100+ PacketSearch requests after sync.
/// Each response triggers updateUserInfo schedulePersist JSON encoding cascade.
/// Missing names are prioritized; online status refresh is limited.
private func refreshOnlineStatusForAllDialogs() async {
let dialogs = DialogRepository.shared.dialogs
let ownKey = currentPublicKey
@@ -1554,6 +1559,7 @@ final class SessionManager {
var count = 0
for key in missingName {
guard ProtocolManager.shared.connectionState == .authenticated else { break }
guard count < 30 else { break } // PERF: cap to prevent request storm
requestUserInfoIfNeeded(opponentKey: key, privateKeyHash: privateKeyHash)
count += 1
if count > 1 {
@@ -1561,8 +1567,18 @@ final class SessionManager {
}
}
// Then refresh online status for dialogs that already have names (300ms stagger)
for key in hasName {
// Then refresh online status for recently active dialogs only (300ms stagger).
// Sort by lastMessageTimestamp descending most recent chats first.
let recentKeys = hasName
.compactMap { key -> (String, Int64)? in
guard let dialog = dialogs[key] else { return nil }
return (key, dialog.lastMessageTimestamp)
}
.sorted { $0.1 > $1.1 }
.prefix(max(0, 30 - count))
.map(\.0)
for key in recentKeys {
guard ProtocolManager.shared.connectionState == .authenticated else { break }
requestUserInfoIfNeeded(opponentKey: key, privateKeyHash: privateKeyHash)
count += 1
@@ -1570,7 +1586,7 @@ final class SessionManager {
try? await Task.sleep(for: .milliseconds(300))
}
}
Self.logger.info("Refreshed user info: \(missingName.count) missing names + \(hasName.count) online status = \(count) total")
Self.logger.info("Refreshed user info: \(missingName.count) missing names + \(recentKeys.count) online status = \(count) total (capped at 30)")
}
/// Persistent handler for ALL search results updates dialog names/usernames from server data.

View File

@@ -0,0 +1,256 @@
import Foundation
import Combine
import QuartzCore
import os
/// Lightweight performance profiler for identifying CPU hotspots.
///
/// Tracks call frequency and duration of critical paths.
/// Output: Xcode console via os.Logger (subsystem: com.rosetta.messenger).
///
/// Usage:
/// PerformanceLogger.shared.track("dialogPersist")
/// PerformanceLogger.shared.measure("imageDownsample") { ... }
///
/// Periodic report (every 10s) shows calls/sec for all tracked events.
/// Enable with `PerformanceLogger.shared.isEnabled = true`.
@MainActor
final class PerformanceLogger {
static let shared = PerformanceLogger()
private static let logger = Logger(subsystem: "com.rosetta.messenger", category: "Perf")
/// Enable for performance testing. Disabled by default zero overhead in production.
#if DEBUG
var isEnabled = false // Set to `true` to enable perf logging
#else
var isEnabled = false
#endif
// MARK: - Event Counters
private var counters: [String: Int] = [:]
private var durations: [String: [CFTimeInterval]] = [:]
private var reportTask: Task<Void, Never>?
private var startTime = CACurrentMediaTime()
private init() {
Self.logger.info("⚡ PerformanceLogger initialized (isEnabled=\(self.isEnabled))")
startPeriodicReport()
}
/// Track a single occurrence of an event (e.g., body evaluation, persist call).
/// Returns `true` so it can be used in SwiftUI body: `let _ = PerformanceLogger.shared.track("x")`
@discardableResult
func track(_ event: String) -> Bool {
guard isEnabled else { return true }
counters[event, default: 0] += 1
return true
}
// MARK: - Frame Drop Detection
private var lastFrameTime: CFTimeInterval = 0
private var frameDrops: Int = 0
private var worstFrameMs: Double = 0
/// Call at start of expensive body eval. Returns token to pass to `endBodyEval`.
func beginBodyEval() -> CFTimeInterval {
CACurrentMediaTime()
}
/// Call at end of body eval. Logs if it took >8ms (frame budget at 120Hz).
func endBodyEval(_ start: CFTimeInterval, label: String) {
guard isEnabled else { return }
let elapsed = (CACurrentMediaTime() - start) * 1000 // ms
if elapsed > 8 { // >8ms = dropped frame at 120Hz
frameDrops += 1
if elapsed > worstFrameMs { worstFrameMs = elapsed }
print("🔴 SLOW \(label): \(String(format: "%.1f", elapsed))ms")
}
}
/// Measure duration of a synchronous block.
func measure<T>(_ event: String, block: () -> T) -> T {
guard isEnabled else { return block() }
let start = CACurrentMediaTime()
let result = block()
let elapsed = CACurrentMediaTime() - start
counters[event, default: 0] += 1
durations[event, default: []].append(elapsed)
// Keep last 50 measurements
if durations[event]!.count > 50 {
durations[event] = Array(durations[event]!.suffix(50))
}
return result
}
/// Measure duration of an async block.
func measureAsync<T>(_ event: String, block: () async throws -> T) async rethrows -> T {
guard isEnabled else { return try await block() }
let start = CACurrentMediaTime()
let result = try await block()
let elapsed = CACurrentMediaTime() - start
counters[event, default: 0] += 1
durations[event, default: []].append(elapsed)
if durations[event]!.count > 50 {
durations[event] = Array(durations[event]!.suffix(50))
}
return result
}
// MARK: - FPS Tracking (real-time)
private var fpsDisplayLink: CADisplayLink?
private var fpsFrameCount: Int = 0
private var fpsLastTimestamp: CFTimeInterval = 0
/// Current FPS updated every 0.5s. Read from FPSOverlay.
private(set) var currentFPS: Int = 0
/// Minimum FPS in last measurement window.
private(set) var minFPS: Int = 60
private var fpsHistory: [Int] = []
/// Start real-time FPS tracking via CADisplayLink.
func startFPSTracking() {
guard fpsDisplayLink == nil else { return }
let link = CADisplayLink(target: FPSDisplayLinkTarget { [weak self] link in
self?.fpsLinkTick(link)
}, selector: #selector(FPSDisplayLinkTarget.tick))
link.add(to: .main, forMode: .common)
fpsDisplayLink = link
fpsLastTimestamp = CACurrentMediaTime()
}
func stopFPSTracking() {
fpsDisplayLink?.invalidate()
fpsDisplayLink = nil
}
private func fpsLinkTick(_ link: CADisplayLink) {
fpsFrameCount += 1
let now = CACurrentMediaTime()
let elapsed = now - fpsLastTimestamp
if elapsed >= 0.5 {
let fps = Int(Double(fpsFrameCount) / elapsed)
currentFPS = fps
fpsFrameCount = 0
fpsLastTimestamp = now
fpsHistory.append(fps)
if fpsHistory.count > 10 { fpsHistory.removeFirst() }
minFPS = fpsHistory.min() ?? fps
}
}
// MARK: - Periodic Report
private func startPeriodicReport() {
reportTask = Task { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(10))
guard let self, self.isEnabled else { continue }
self.printReport()
}
}
}
private func printReport() {
let elapsed = CACurrentMediaTime() - startTime
guard elapsed > 1, !counters.isEmpty else { return }
var lines: [String] = ["⚡ PERF (last \(Int(elapsed))s) FPS:\(currentFPS) min:\(minFPS) drops:\(frameDrops) worst:\(String(format: "%.0f", worstFrameMs))ms:"]
frameDrops = 0
worstFrameMs = 0
let sortedEvents = counters.sorted { $0.value > $1.value }
for (event, count) in sortedEvents {
let perSec = Double(count) / elapsed
var line = " \(event): \(count)× (\(String(format: "%.1f", perSec))/s)"
if let durs = durations[event], !durs.isEmpty {
let avg = durs.reduce(0, +) / Double(durs.count)
let max = durs.max() ?? 0
line += " avg=\(String(format: "%.1f", avg * 1000))ms max=\(String(format: "%.1f", max * 1000))ms"
}
lines.append(line)
}
let report = lines.joined(separator: "\n")
#if DEBUG
print(report)
#endif
Self.logger.info("\(report)")
// Reset counters for next period
counters.removeAll(keepingCapacity: true)
durations.removeAll(keepingCapacity: true)
startTime = CACurrentMediaTime()
}
}
// MARK: - CADisplayLink target (avoids @objc on @MainActor)
private class FPSDisplayLinkTarget {
let handler: (CADisplayLink) -> Void
init(handler: @escaping (CADisplayLink) -> Void) { self.handler = handler }
@objc func tick(_ link: CADisplayLink) { handler(link) }
}
// MARK: - FPS Overlay View
import SwiftUI
/// Floating FPS counter add to any view with `.overlay { FPSOverlayView() }`.
/// Shows current FPS (green/yellow/red) and min FPS over last 5s.
/// Drag to reposition. Double-tap to dismiss.
struct FPSOverlayView: View {
@State private var fps: Int = 0
@State private var minFPS: Int = 60
@State private var offset: CGSize = .zero
@State private var isVisible = true
private let timer = Timer.publish(every: 0.5, on: .main, in: .common).autoconnect()
var body: some View {
if isVisible {
VStack(alignment: .leading, spacing: 2) {
Text("\(fps) FPS")
.font(.system(size: 13, weight: .bold, design: .monospaced))
.foregroundStyle(fpsColor)
Text("min \(minFPS)")
.font(.system(size: 10, weight: .medium, design: .monospaced))
.foregroundStyle(minFPSColor)
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.black.opacity(0.75))
.clipShape(RoundedRectangle(cornerRadius: 8))
.offset(offset)
.gesture(
DragGesture()
.onChanged { offset = $0.translation }
)
.onTapGesture(count: 2) { isVisible = false }
.onReceive(timer) { _ in
fps = PerformanceLogger.shared.currentFPS
minFPS = PerformanceLogger.shared.minFPS
}
.onAppear { PerformanceLogger.shared.startFPSTracking() }
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding(.leading, 16)
.padding(.top, 55)
.allowsHitTesting(true)
}
}
private var fpsColor: Color {
if fps >= 55 { return .green }
if fps >= 30 { return .yellow }
return .red
}
private var minFPSColor: Color {
if minFPS >= 45 { return .green.opacity(0.7) }
if minFPS >= 25 { return .yellow.opacity(0.7) }
return .red.opacity(0.7)
}
}

View File

@@ -0,0 +1,157 @@
import UIKit
/// Debug-only stress test: generates fake messages with real images to test performance.
@MainActor
enum StressTestGenerator {
static func generateMessages(count: Int, dialogKey: String) {
let myKey = SessionManager.shared.currentPublicKey
guard !myKey.isEmpty else { return }
// Generate and cache a few real images so MessageImageView shows actual content
let imageIds = prepareTestImages()
let now = Int64(Date().timeIntervalSince1970 * 1000)
let texts = [
"Привет, как дела?", "Всё отлично 😊", "👍",
"Посмотри это обновление, новые фичи огонь. Шифрование улучшили и UI переделали полностью.",
"Отправлю файл", "Окей", "Short", "🎉🎊🥳",
"Встреча в 3 завтра?", "Давай 👌", "Ну чё как?",
"Это очень длинное сообщение которое тестирует как приложение обрабатывает большие объёмы текста в одном бабле. Должно переноситься корректно и не вызывать проблем с layout. Производительность должна оставаться плавной даже при нескольких строках текста.",
"**Bold** и обычный текст", "Лол 😂😂😂", "Сейчас скину",
"Чё делаешь?", "Работаю", "Пойдём гулять?", "Не могу сегодня",
"Завтра тогда", "Ладно", "Спокойной ночи 🌙", "Утро ☀️",
"Кофе?", "Да, давай", "Где?", "Как обычно", "Ок буду через 10",
"Уже тут", "Иду", "Заказал тебе латте"
]
for i in 0..<count {
let isOutgoing = i % 3 != 0
let timestamp = now - Int64((count - i) * 15_000) // 15s apart
let messageId = "stress_\(UUID().uuidString.prefix(8))"
let text = texts[i % texts.count]
var attachments: [MessageAttachment] = []
// Every 5th message has a photo (20% have images realistic ratio)
if i % 5 == 0, !imageIds.isEmpty {
let imgId = imageIds[i % imageIds.count]
attachments.append(MessageAttachment(
id: imgId,
preview: "cached::LKO2:N%2Syay-;jtR*.7oe{$jbIU",
blob: "",
type: .image
))
}
// Every 15th message has a file
if i % 15 == 0 {
attachments.append(MessageAttachment(
id: "file_\(messageId)",
preview: "fake_tag::12345::document.pdf",
blob: "",
type: .file
))
}
// Some messages with 2-3 photos (collage)
if i % 20 == 0, imageIds.count >= 3 {
attachments = (0..<3).map { j in
MessageAttachment(
id: imageIds[(i + j) % imageIds.count],
preview: "cached::LKO2:N%2Syay-;jtR*.7oe{$jbIU",
blob: "",
type: .image
)
}
}
let message = ChatMessage(
id: messageId,
fromPublicKey: isOutgoing ? myKey : dialogKey,
toPublicKey: isOutgoing ? dialogKey : myKey,
text: attachments.isEmpty ? text : (i % 2 == 0 ? text : ""),
timestamp: timestamp,
deliveryStatus: isOutgoing ? [.delivered, .read, .delivered][i % 3] : .delivered,
isRead: true,
attachments: attachments,
attachmentPassword: nil
)
MessageRepository.shared.insertStressTestMessage(message, dialogKey: dialogKey)
}
DialogRepository.shared.ensureDialog(
opponentKey: dialogKey,
title: "Stress Test (\(count))",
username: "stress_test",
verified: 0,
myPublicKey: myKey
)
print("🧪 StressTest: generated \(count) messages (\(imageIds.count) cached images)")
}
// MARK: - Generate real test images and cache them
/// Creates colored gradient images and saves to AttachmentCache.
/// Returns array of attachment IDs that have cached images.
private static func prepareTestImages() -> [String] {
let colors: [(UIColor, UIColor)] = [
(.systemBlue, .systemPurple),
(.systemOrange, .systemRed),
(.systemGreen, .systemTeal),
(.systemPink, .systemIndigo),
(.systemYellow, .systemOrange),
(.systemCyan, .systemBlue),
]
var ids: [String] = []
let size = CGSize(width: 400, height: 300)
for (i, (c1, c2)) in colors.enumerated() {
let id = "stress_img_\(i)"
// Check if already cached
if AttachmentCache.shared.loadImage(forAttachmentId: id) != nil {
ids.append(id)
continue
}
// Generate gradient image
let renderer = UIGraphicsImageRenderer(size: size)
let image = renderer.image { ctx in
let gradient = CGGradient(
colorsSpace: CGColorSpaceCreateDeviceRGB(),
colors: [c1.cgColor, c2.cgColor] as CFArray,
locations: [0, 1]
)!
ctx.cgContext.drawLinearGradient(
gradient,
start: .zero,
end: CGPoint(x: size.width, y: size.height),
options: []
)
// Draw text label
let text = "Test \(i + 1)" as NSString
let attrs: [NSAttributedString.Key: Any] = [
.font: UIFont.boldSystemFont(ofSize: 32),
.foregroundColor: UIColor.white
]
let textSize = text.size(withAttributes: attrs)
text.draw(
at: CGPoint(
x: (size.width - textSize.width) / 2,
y: (size.height - textSize.height) / 2
),
withAttributes: attrs
)
}
AttachmentCache.shared.saveImage(image, forAttachmentId: id)
ids.append(id)
}
return ids
}
}

View File

@@ -23,6 +23,9 @@ final class KeyboardTracker: ObservableObject {
@Published private(set) var keyboardPadding: CGFloat = 0
private var isAnimating = false
/// Public flag for BubbleContextMenuOverlay to skip updateUIView during animation.
/// NOT @Published read directly from UIViewRepresentable, no observation.
private(set) var isAnimatingKeyboard = false
private let bottomInset: CGFloat
private var pendingResetTask: Task<Void, Never>?
private var cancellables = Set<AnyCancellable>()
@@ -81,6 +84,7 @@ final class KeyboardTracker: ObservableObject {
/// to reduce ChatDetailView.body evaluations during swipe-to-dismiss.
func updateFromKVO(keyboardHeight: CGFloat) {
if #available(iOS 26, *) { return }
PerformanceLogger.shared.track("keyboard.kvo")
guard !isAnimating else { return }
if keyboardHeight <= 0 {
@@ -119,7 +123,7 @@ final class KeyboardTracker: ObservableObject {
// Start coalescing display link if not running
if kvoDisplayLink == nil {
kvoDisplayLink = DisplayLinkProxy(preferredFPS: 30) { [weak self] in
kvoDisplayLink = DisplayLinkProxy(maxFPS: 30) { [weak self] in
self?.applyPendingKVO()
}
}
@@ -133,6 +137,8 @@ final class KeyboardTracker: ObservableObject {
return
}
pendingKVOPadding = nil
// Guard: KVO can produce NaN during edge cases (view hierarchy changes)
guard pending.isFinite, pending >= 0 else { return }
guard pending != keyboardPadding else { return }
keyboardPadding = pending
}
@@ -176,22 +182,18 @@ final class KeyboardTracker: ObservableObject {
let delta = targetPadding - lastNotificationPadding
lastNotificationPadding = targetPadding
// Guard: skip animation when target equals current padding (e.g., after
// interactive dismiss already brought padding to 0, the late notification
// would start a wasted 00 animation with ~14 no-op CADisplayLink ticks).
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 abs(delta) > 1, targetPadding != keyboardPadding {
// CADisplayLink interpolation: updates @Published at ~60fps.
// Each frame is a small layout delta LazyVStack handles it without
// cell recycling no gaps between message bubbles.
isAnimatingKeyboard = true
startPaddingAnimation(to: targetPadding, duration: duration, curveRaw: curveRaw)
} else {
// Still snap to target in case of rounding differences
if keyboardPadding != targetPadding {
keyboardPadding = targetPadding
}
} else if keyboardPadding != targetPadding {
keyboardPadding = targetPadding
}
// Unblock KVO after animation + buffer.
// Unblock KVO after keyboard settles.
let unblockDelay = max(duration, 0.05) + 0.15
Task { @MainActor [weak self] in
try? await Task.sleep(for: .seconds(unblockDelay))
@@ -199,7 +201,44 @@ final class KeyboardTracker: ObservableObject {
}
}
// MARK: - CADisplayLink animation
// MARK: - Stepped Animation (4 keyframes)
private var steppedAnimationTask: Task<Void, Never>?
/// Animates keyboardPadding in 4 steps over ~duration.
/// Each step triggers 1 layout pass. Total = 4 passes instead of 15.
/// Visually smooth enough for 250ms keyboard animation.
private func startSteppedAnimation(to target: CGFloat, duration: CFTimeInterval) {
steppedAnimationTask?.cancel()
let start = keyboardPadding
let safeDuration = max(duration, 0.1)
let steps = 4
let stepDelay = safeDuration / Double(steps)
steppedAnimationTask = Task { @MainActor [weak self] in
for i in 1...steps {
guard !Task.isCancelled, let self else { return }
let fraction = Double(i) / Double(steps)
// Ease-out curve: fast start, slow end
let eased = 1 - pow(1 - fraction, 2)
let value = max(0, round(start + (target - start) * eased))
if value != self.keyboardPadding {
PerformanceLogger.shared.track("keyboard.step")
self.keyboardPadding = value
}
if i < steps {
try? await Task.sleep(for: .milliseconds(Int(stepDelay * 1000)))
}
}
// Ensure we hit the exact target
guard let self, !Task.isCancelled else { return }
if self.keyboardPadding != target {
self.keyboardPadding = max(0, target)
}
}
}
// MARK: - CADisplayLink animation (legacy, kept for reference)
private func startPaddingAnimation(to target: CGFloat, duration: CFTimeInterval, curveRaw: Int) {
animationNumber += 1
@@ -222,6 +261,8 @@ final class KeyboardTracker: ObservableObject {
// 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 {
@@ -267,6 +308,8 @@ final class KeyboardTracker: ObservableObject {
}
private func animationTick() {
let tickStart = CACurrentMediaTime()
PerformanceLogger.shared.track("keyboard.animTick")
animTickCount += 1
let now = CACurrentMediaTime()
@@ -289,19 +332,37 @@ final class KeyboardTracker: ObservableObject {
isComplete = t >= 1.0
}
// Round to nearest 1pt sub-point changes are invisible but still
// trigger full SwiftUI layout passes. Skipping them reduces render cost.
// 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.
guard eased.isFinite else {
displayLinkProxy?.isPaused = true
lastTickTime = 0
isAnimatingKeyboard = false
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 = round(raw)
let rounded = max(0, round(raw))
if isComplete || animTickCount > 30 {
keyboardPadding = animTargetPadding
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")
}
#endif
}
// MARK: - Cubic bezier fallback
@@ -360,15 +421,18 @@ private class DisplayLinkProxy {
private var callback: (() -> Void)?
private var displayLink: CADisplayLink?
/// - Parameter preferredFPS: Target frame rate. 60 for notification animation,
/// 30 for KVO coalescing (halves body evaluations during interactive dismiss).
init(preferredFPS: Int = 60, callback: @escaping () -> Void) {
/// - Parameter maxFPS: Max frame rate. 0 = device native (120Hz on ProMotion).
/// Non-zero values cap via preferredFrameRateRange.
init(maxFPS: Int = 0, callback: @escaping () -> Void) {
self.callback = callback
self.displayLink = CADisplayLink(target: self, selector: #selector(tick))
let fps = Float(preferredFPS)
self.displayLink?.preferredFrameRateRange = CAFrameRateRange(
minimum: fps / 2, maximum: fps, preferred: fps
)
if maxFPS > 0 {
let fps = Float(maxFPS)
self.displayLink?.preferredFrameRateRange = CAFrameRateRange(
minimum: fps / 2, maximum: fps, preferred: fps
)
}
// maxFPS == 0: no range set runs at device native refresh rate (120Hz ProMotion)
self.displayLink?.add(to: .main, forMode: .common)
}

View File

@@ -344,6 +344,7 @@ struct AttachmentPanelView: View {
.padding(4)
.background { tabBarBackground }
.clipShape(Capsule())
.contentShape(Capsule())
.tabBarShadow()
}
@@ -356,7 +357,13 @@ struct AttachmentPanelView: View {
.fill(.clear)
.glassEffect(.regular, in: .capsule)
} else {
TelegramGlassCapsule()
// iOS < 26 matches RosettaTabBar: .regularMaterial + border
Capsule()
.fill(.regularMaterial)
.overlay(
Capsule()
.strokeBorder(Color.white.opacity(0.08), lineWidth: 0.5)
)
}
}
@@ -400,7 +407,10 @@ struct AttachmentPanelView: View {
.fill(.clear)
.glassEffect(.regular, in: .capsule)
} else {
TelegramGlassCapsule()
// Matches RosettaTabBar selection indicator: .thinMaterial
Capsule()
.fill(.thinMaterial)
.padding(.vertical, 2)
}
}
}

View File

@@ -52,11 +52,11 @@ struct BubbleContextMenuOverlay: UIViewRepresentable {
}
func updateUIView(_ uiView: UIView, context: Context) {
context.coordinator.actions = actions
context.coordinator.previewShape = previewShape
context.coordinator.readStatusText = readStatusText
// PERF: only update callbacks (lightweight pointer swap).
// Skip actions/previewShape/readStatusText these involve array allocation
// and struct copying on EVERY layout pass (40× cells × 8 keyboard ticks = 320/s).
// Context menu will use stale actions until cell is recycled acceptable trade-off.
context.coordinator.onTap = onTap
context.coordinator.replyQuoteHeight = replyQuoteHeight
context.coordinator.onReplyQuoteTap = onReplyQuoteTap
}

View File

@@ -17,6 +17,7 @@ private struct KeyboardSpacer: View {
let composerHeight: CGFloat
var body: some View {
let _ = PerformanceLogger.shared.track("keyboardSpacer.bodyEval")
Color.clear.frame(height: composerHeight + keyboard.keyboardPadding + 4)
}
}
@@ -34,6 +35,7 @@ private struct KeyboardPaddedView<Content: View>: View {
}
var body: some View {
let _ = PerformanceLogger.shared.track("keyboardPadded.bodyEval")
content.offset(y: -(keyboard.keyboardPadding + extraPadding))
}
}
@@ -87,8 +89,8 @@ struct ChatDetailView: View {
@State private var showForwardPicker = false
@State private var forwardingMessage: ChatMessage?
@State private var messageToDelete: ChatMessage?
/// State for the multi-photo gallery viewer (nil = dismissed).
@State private var imageViewerState: ImageViewerState?
// Image viewer is presented via ImageViewerPresenter (UIKit overFullScreen),
// not via SwiftUI fullScreenCover, to avoid bottom-sheet slide-up animation.
/// ID of message to scroll to (set when tapping a reply quote).
@State private var scrollToMessageId: String?
/// ID of message currently highlighted after scroll-to-reply navigation.
@@ -173,10 +175,13 @@ struct ChatDetailView: View {
@ViewBuilder
private var content: some View {
let _ = PerformanceLogger.shared.track("chatDetail.bodyEval")
ZStack {
messagesList(maxBubbleWidth: maxBubbleWidth)
}
.overlay { chatEdgeGradients }
// FPS overlay uncomment for performance testing:
// .overlay { FPSOverlayView() }
.overlay(alignment: .bottom) {
if !route.isSystemAccount {
KeyboardPaddedView {
@@ -286,17 +291,8 @@ struct ChatDetailView: View {
forwardMessage(message, to: targetRoute)
}
}
.fullScreenCover(isPresented: Binding(
get: { imageViewerState != nil },
set: { if !$0 { imageViewerState = nil } }
)) {
if let state = imageViewerState {
ImageGalleryViewer(
state: state,
onDismiss: { imageViewerState = nil }
)
}
}
// Image viewer: presented via ImageViewerPresenter (UIKit overFullScreen + crossDissolve).
// No .fullScreenCover avoids the default bottom-sheet slide-up animation.
.alert("Delete Message", isPresented: Binding(
get: { messageToDelete != nil },
set: { if !$0 { messageToDelete = nil } }
@@ -688,14 +684,11 @@ private extension ChatDetailView {
.frame(height: 4)
.id(Self.scrollBottomAnchorId)
// Spacer for composer + keyboard OUTSIDE LazyVStack so padding
// changes only shift the LazyVStack as a whole block (cheap),
// instead of re-laying out every cell inside it (expensive).
// Spacer for composer + keyboard OUTSIDE LazyVStack.
// Isolated in KeyboardSpacer to avoid marking parent dirty.
KeyboardSpacer(composerHeight: composerHeight)
// LazyVStack: only visible cells are loaded. Internal layout
// is unaffected by the spacer above changing height.
// LazyVStack: only visible cells are loaded.
LazyVStack(spacing: 0) {
// Sentinel for viewport-based scroll tracking.
// Must be inside LazyVStack regular VStack doesn't
@@ -707,21 +700,26 @@ private extension ChatDetailView {
// PERF: iterate reversed messages directly, avoid Array(enumerated()) allocation.
// Use message.id identity (stable) integer indices shift on insert.
// PERF: VStack wrapper ensures each ForEach element produces
// exactly 1 view SwiftUI uses FAST PATH (O(1) diffing).
// Without it: conditional unreadSeparator makes element count
// variable SLOW PATH (O(n) full scan on every update).
ForEach(messages.reversed()) { message in
let index = messageIndex(for: message.id)
let position = bubblePosition(for: index)
messageRow(
message,
maxBubbleWidth: maxBubbleWidth,
position: position
)
.scaleEffect(x: 1, y: -1) // flip each row back to normal
VStack(spacing: 0) {
let index = messageIndex(for: message.id)
let position = bubblePosition(for: index)
messageRow(
message,
maxBubbleWidth: maxBubbleWidth,
position: position
)
.scaleEffect(x: 1, y: -1) // flip each row back to normal
// Unread Messages separator (Telegram style).
// In inverted scroll, "above" visually = after in code.
if message.id == firstUnreadMessageId {
unreadSeparator
.scaleEffect(x: 1, y: -1)
// Unread Messages separator (Telegram style).
if message.id == firstUnreadMessageId {
unreadSeparator
.scaleEffect(x: 1, y: -1)
}
}
}
}
@@ -820,6 +818,7 @@ private extension ChatDetailView {
@ViewBuilder
func messageRow(_ message: ChatMessage, maxBubbleWidth: CGFloat, position: BubblePosition) -> some View {
let _ = PerformanceLogger.shared.track("chatDetail.rowEval")
let outgoing = message.isFromMe(myPublicKey: currentPublicKey)
let hasTail = position == .single || position == .bottom
@@ -974,14 +973,14 @@ private extension ChatDetailView {
.padding(.top, 3)
// Forwarded image attachments blurhash thumbnails (Android parity: ForwardedImagePreview).
ForEach(Array(imageAttachments.enumerated()), id: \.element.id) { _, att in
ForEach(imageAttachments, id: \.id) { att in
forwardedImagePreview(attachment: att, width: imageContentWidth, outgoing: outgoing)
.padding(.horizontal, 6)
.padding(.top, 4)
}
// Forwarded file attachments.
ForEach(Array(fileAttachments.enumerated()), id: \.element.id) { _, att in
ForEach(fileAttachments, id: \.id) { att in
forwardedFilePreview(attachment: att, outgoing: outgoing)
.padding(.horizontal, 6)
.padding(.top, 4)
@@ -1221,27 +1220,30 @@ private extension ChatDetailView {
}
// Non-image attachments (file, avatar) padded
// PERF: Group ensures 1 view per element ForEach fast path.
ForEach(otherAttachments, id: \.id) { attachment in
switch attachment.type {
case .file:
MessageFileView(
attachment: attachment,
message: message,
outgoing: outgoing
)
.padding(.horizontal, 4)
.padding(.top, 4)
case .avatar:
MessageAvatarView(
attachment: attachment,
message: message,
outgoing: outgoing
)
Group {
switch attachment.type {
case .file:
MessageFileView(
attachment: attachment,
message: message,
outgoing: outgoing
)
.padding(.horizontal, 4)
.padding(.top, 4)
case .avatar:
MessageAvatarView(
attachment: attachment,
message: message,
outgoing: outgoing
)
.padding(.horizontal, 6)
.padding(.top, 4)
default:
EmptyView()
}
}
}
// Caption text below image
@@ -1384,7 +1386,11 @@ private extension ChatDetailView {
/// Desktop parity: `TextParser.tsx` pattern `/:emoji_([a-zA-Z0-9_-]+):/`
/// Android parity: `unifiedToEmoji()` in `AppleEmojiPicker.kt`
private func parsedMarkdown(_ text: String) -> AttributedString {
if let cached = Self.markdownCache[text] { return cached }
if let cached = Self.markdownCache[text] {
PerformanceLogger.shared.track("markdown.cacheHit")
return cached
}
PerformanceLogger.shared.track("markdown.cacheMiss")
// Cross-platform: replace :emoji_CODE: shortcodes with native Unicode emoji.
let withEmoji = EmojiParser.replaceShortcodes(in: text)
@@ -1802,12 +1808,14 @@ private extension ChatDetailView {
)
}
ForEach(otherAttachments, id: \.id) { attachment in
switch attachment.type {
case .file:
MessageFileView(attachment: attachment, message: message, outgoing: outgoing).padding(.horizontal, 4).padding(.top, 4)
case .avatar:
MessageAvatarView(attachment: attachment, message: message, outgoing: outgoing).padding(.horizontal, 6).padding(.top, 4)
default: EmptyView()
Group {
switch attachment.type {
case .file:
MessageFileView(attachment: attachment, message: message, outgoing: outgoing).padding(.horizontal, 4).padding(.top, 4)
case .avatar:
MessageAvatarView(attachment: attachment, message: message, outgoing: outgoing).padding(.horizontal, 6).padding(.top, 4)
default: EmptyView()
}
}
}
if hasCaption {
@@ -1934,16 +1942,28 @@ private extension ChatDetailView {
}
}
/// Collects all image attachment IDs from the current chat and opens the gallery.
/// Collects all image attachments from the current chat and opens the gallery.
/// Android parity: `extractImagesFromMessages` in `ImageViewerScreen.kt` includes
/// sender name, timestamp, and caption for each image.
/// Uses `ImageViewerPresenter` (UIKit overFullScreen) instead of SwiftUI fullScreenCover
/// to avoid the default bottom-sheet slide-up animation.
func openImageViewer(attachmentId: String) {
var allImageIds: [String] = []
var allImages: [ViewableImageInfo] = []
for message in messages {
let senderName = senderDisplayName(for: message.fromPublicKey)
let timestamp = Date(timeIntervalSince1970: Double(message.timestamp) / 1000)
for attachment in message.attachments where attachment.type == .image {
allImageIds.append(attachment.id)
allImages.append(ViewableImageInfo(
attachmentId: attachment.id,
senderName: senderName,
timestamp: timestamp,
caption: message.text
))
}
}
let index = allImageIds.firstIndex(of: attachmentId) ?? 0
imageViewerState = ImageViewerState(attachmentIds: allImageIds, initialIndex: index)
let index = allImages.firstIndex(where: { $0.attachmentId == attachmentId }) ?? 0
let state = ImageViewerState(images: allImages, initialIndex: index)
ImageViewerPresenter.shared.present(state: state)
}
func retryMessage(_ message: ChatMessage) {

View File

@@ -27,10 +27,13 @@ final class ChatDetailViewModel: ObservableObject {
// Subscribe to messagesByDialog changes, filtered to our dialog only.
// Broken into steps to help the Swift type-checker.
let key = dialogKey
// Android parity: debounce(50ms) batches rapid message mutations
// (delivery status, read marks, sync bursts) into fewer UI updates.
let messagesPublisher = repo.$messagesByDialog
.map { (dict: [String: [ChatMessage]]) -> [ChatMessage] in
dict[key] ?? []
}
.debounce(for: .milliseconds(50), scheduler: DispatchQueue.main)
.removeDuplicates { (lhs: [ChatMessage], rhs: [ChatMessage]) -> Bool in
guard lhs.count == rhs.count else { return false }
for i in lhs.indices {
@@ -40,10 +43,10 @@ final class ChatDetailViewModel: ObservableObject {
}
return true
}
.receive(on: DispatchQueue.main)
messagesPublisher
.sink { [weak self] newMessages in
PerformanceLogger.shared.track("chatDetail.messagesEmit")
self?.messages = newMessages
}
.store(in: &cancellables)

View File

@@ -4,17 +4,76 @@ import Photos
// MARK: - Data Types
/// Per-image metadata for the gallery viewer.
/// Android parity: `ViewableImage` in `ImageViewerScreen.kt`.
struct ViewableImageInfo: Equatable, Identifiable {
let attachmentId: String
let senderName: String
let timestamp: Date
let caption: String
var id: String { attachmentId }
}
/// State for the image gallery viewer.
struct ImageViewerState: Equatable {
let attachmentIds: [String]
let images: [ViewableImageInfo]
let initialIndex: Int
}
// MARK: - ImageViewerPresenter
/// UIHostingController subclass that hides the status bar.
/// Uses `AnyView` instead of generic `Content` to avoid a Swift compiler crash
/// in the SIL inliner (SR-XXXXX / rdar://XXXXX).
private final class StatusBarHiddenHostingController: UIHostingController<AnyView> {
override var prefersStatusBarHidden: Bool { true }
override var preferredStatusBarUpdateAnimation: UIStatusBarAnimation { .fade }
}
/// Presents the image gallery viewer using UIKit `overFullScreen` presentation
/// no bottom-sheet slide-up. Appears instantly; the viewer itself fades in.
/// Telegram parity: the viewer appears as a fade overlay covering nav bar and tab bar.
@MainActor
final class ImageViewerPresenter {
static let shared = ImageViewerPresenter()
private weak var presentedController: UIViewController?
func present(state: ImageViewerState) {
guard presentedController == nil else { return }
let viewer = ImageGalleryViewer(state: state, onDismiss: { [weak self] in
self?.dismiss()
})
let hostingController = StatusBarHiddenHostingController(rootView: AnyView(viewer))
hostingController.modalPresentationStyle = .overFullScreen
hostingController.view.backgroundColor = .clear
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let root = windowScene.keyWindow?.rootViewController
else { return }
var presenter = root
while let presented = presenter.presentedViewController {
presenter = presented
}
presenter.present(hostingController, animated: false)
presentedController = hostingController
}
func dismiss() {
presentedController?.dismiss(animated: false)
presentedController = nil
}
}
// MARK: - ImageGalleryViewer
/// Telegram-style multi-photo gallery viewer with horizontal paging.
/// Android parity: `ImageViewerScreen.kt` HorizontalPager, zoom-to-point,
/// velocity dismiss, page counter, share/save.
/// Android parity: `ImageViewerScreen.kt` top bar with sender/date,
/// bottom caption bar, edge-tap navigation, velocity dismiss, share/save.
struct ImageGalleryViewer: View {
let state: ImageViewerState
@@ -23,6 +82,16 @@ struct ImageGalleryViewer: View {
@State private var currentPage: Int
@State private var showControls = true
@State private var currentZoomScale: CGFloat = 1.0
@State private var backgroundOpacity: Double = 1.0
@State private var isDismissing = false
/// Entry/exit animation progress (0 = hidden, 1 = fully visible).
@State private var presentationAlpha: Double = 0
private static let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "d MMMM, HH:mm"
return formatter
}()
init(state: ImageViewerState, onDismiss: @escaping () -> Void) {
self.state = state
@@ -30,76 +99,135 @@ struct ImageGalleryViewer: View {
self._currentPage = State(initialValue: state.initialIndex)
}
private var currentInfo: ViewableImageInfo? {
state.images.indices.contains(currentPage) ? state.images[currentPage] : nil
}
var body: some View {
ZStack {
Color.black.ignoresSafeArea()
// Background fades during drag-to-dismiss and entry/exit
Color.black
.opacity(backgroundOpacity * presentationAlpha)
.ignoresSafeArea()
// Pager
TabView(selection: $currentPage) {
ForEach(Array(state.attachmentIds.enumerated()), id: \.element) { index, attachmentId in
ForEach(Array(state.images.enumerated()), id: \.element.attachmentId) { index, info in
ZoomableImagePage(
attachmentId: attachmentId,
onDismiss: onDismiss,
attachmentId: info.attachmentId,
onDismiss: { smoothDismiss() },
onDismissProgress: { progress in
backgroundOpacity = 1.0 - Double(progress) * 0.7
},
onDismissCancel: {
withAnimation(.easeOut(duration: 0.25)) {
backgroundOpacity = 1.0
}
},
showControls: $showControls,
currentScale: $currentZoomScale
currentScale: $currentZoomScale,
onEdgeTap: { direction in
navigateEdgeTap(direction: direction)
}
)
.tag(index)
}
}
.tabViewStyle(.page(indexDisplayMode: .never))
.disabled(currentZoomScale > 1.05)
.disabled(currentZoomScale > 1.05 || isDismissing)
.opacity(presentationAlpha)
// Controls overlay
if showControls {
controlsOverlay
.transition(.opacity)
}
controlsOverlay
.opacity(presentationAlpha)
}
.statusBarHidden(true)
.animation(.easeInOut(duration: 0.2), value: showControls)
.onChange(of: currentPage) { _, newPage in
prefetchAdjacentImages(around: newPage)
}
.onAppear {
prefetchAdjacentImages(around: state.initialIndex)
// Android: 200ms entry animation (TelegramEasing)
withAnimation(.easeOut(duration: 0.2)) {
presentationAlpha = 1.0
}
}
.onChange(of: currentPage) { _, newPage in
prefetchAdjacentImages(around: newPage)
}
}
// MARK: - Controls Overlay
@ViewBuilder
private var controlsOverlay: some View {
VStack {
// Top bar: close + counter inside safe area to avoid notch/Dynamic Island overlap
HStack {
Button { onDismiss() } label: {
Image(systemName: "xmark")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 36, height: 36)
.background(Color.white.opacity(0.2))
.clipShape(Circle())
}
.padding(.leading, 16)
if showControls && !isDismissing {
VStack(spacing: 0) {
topBar
Spacer()
if state.attachmentIds.count > 1 {
Text("\(currentPage + 1) / \(state.attachmentIds.count)")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(.white.opacity(0.8))
}
Spacer()
// Invisible spacer to balance the close button
Color.clear.frame(width: 36, height: 36)
.padding(.trailing, 16)
bottomBar
}
.transition(.opacity)
.animation(.easeInOut(duration: 0.2), value: showControls)
}
}
// MARK: - Top Bar (Android: sender name + date, back arrow)
private var topBar: some View {
HStack(spacing: 8) {
// Back button (Android: arrow back on left)
Button { smoothDismiss() } label: {
Image(systemName: "chevron.left")
.font(.system(size: 20, weight: .medium))
.foregroundStyle(.white)
.frame(width: 44, height: 44)
}
// Sender name + date
if let info = currentInfo {
VStack(alignment: .leading, spacing: 1) {
Text(info.senderName)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.white)
.lineLimit(1)
Text(Self.dateFormatter.string(from: info.timestamp))
.font(.system(size: 13))
.foregroundStyle(.white.opacity(0.7))
}
}
.padding(.top, 54)
Spacer()
// Bottom bar: share + save
// Page counter (if multiple images)
if state.images.count > 1 {
Text("\(currentPage + 1) / \(state.images.count)")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(.white.opacity(0.8))
.padding(.trailing, 8)
}
}
.padding(.horizontal, 4)
.padding(.vertical, 8)
// Extend dark background up into the notch / Dynamic Island safe area
.background(Color.black.opacity(0.5).ignoresSafeArea(edges: .top))
}
// MARK: - Bottom Bar (Caption + Share/Save)
private var bottomBar: some View {
VStack(spacing: 0) {
// Caption text (Android: AppleEmojiText, 15sp, 4 lines max)
if let caption = currentInfo?.caption, !caption.isEmpty {
Text(caption)
.font(.system(size: 15))
.foregroundStyle(.white)
.lineLimit(4)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(Color.black.opacity(0.5))
}
// Action buttons
HStack(spacing: 32) {
Button { shareCurrentImage() } label: {
Image(systemName: "square.and.arrow.up")
@@ -118,15 +246,41 @@ struct ImageGalleryViewer: View {
}
}
.padding(.horizontal, 24)
.padding(.bottom, 34)
.padding(.bottom, 8)
// Extend dark background down into the home indicator safe area
.background(Color.black.opacity(0.5).ignoresSafeArea(edges: .bottom))
}
}
// MARK: - Edge Tap Navigation
private func navigateEdgeTap(direction: Int) {
let target = currentPage + direction
guard target >= 0, target < state.images.count else { return }
// Android: instant page switch with short fade (120ms)
currentPage = target
}
// MARK: - Smooth Dismiss (Android: 200ms fade-out)
private func smoothDismiss() {
guard !isDismissing else { return }
isDismissing = true
withAnimation(.easeOut(duration: 0.2)) {
presentationAlpha = 0
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.22) {
onDismiss()
}
}
// MARK: - Actions
private func shareCurrentImage() {
guard currentPage < state.attachmentIds.count,
let image = AttachmentCache.shared.loadImage(forAttachmentId: state.attachmentIds[currentPage])
guard let info = currentInfo,
let image = AttachmentCache.shared.loadImage(forAttachmentId: info.attachmentId)
else { return }
let activityVC = UIActivityViewController(activityItems: [image], applicationActivities: nil)
@@ -146,8 +300,8 @@ struct ImageGalleryViewer: View {
}
private func saveCurrentImage() {
guard currentPage < state.attachmentIds.count,
let image = AttachmentCache.shared.loadImage(forAttachmentId: state.attachmentIds[currentPage])
guard let info = currentInfo,
let image = AttachmentCache.shared.loadImage(forAttachmentId: info.attachmentId)
else { return }
PHPhotoLibrary.requestAuthorization(for: .addOnly) { status in
@@ -159,11 +313,12 @@ struct ImageGalleryViewer: View {
// MARK: - Prefetch
private func prefetchAdjacentImages(around index: Int) {
for offset in [-1, 1] {
// Android: prefetches ±2 images from current page
for offset in [-2, -1, 1, 2] {
let i = index + offset
guard i >= 0, i < state.attachmentIds.count else { continue }
// Touch cache to warm it (loads from disk if needed)
_ = AttachmentCache.shared.loadImage(forAttachmentId: state.attachmentIds[i])
guard i >= 0, i < state.images.count else { continue }
_ = AttachmentCache.shared.loadImage(forAttachmentId: state.images[i].attachmentId)
}
}
}

View File

@@ -152,11 +152,15 @@ struct MessageImageView: View {
/// Calculates display size respecting min/max constraints and aspect ratio (standalone mode).
private func constrainedSize(for img: UIImage) -> CGSize {
let constrainedWidth = min(maxImageWidth, maxWidth)
let aspectRatio = img.size.width / max(img.size.height, 1)
// Guard: zero-size images (corrupted or failed downsampling) use placeholder size.
guard img.size.width > 0, img.size.height > 0 else {
return CGSize(width: min(placeholderWidth, constrainedWidth), height: placeholderHeight)
}
let aspectRatio = img.size.width / img.size.height
let displayWidth = min(constrainedWidth, max(minImageWidth, img.size.width))
let displayHeight = min(maxImageHeight, max(minImageHeight, displayWidth / aspectRatio))
let displayHeight = min(maxImageHeight, max(minImageHeight, displayWidth / max(aspectRatio, 0.01)))
let finalWidth = min(constrainedWidth, displayHeight * aspectRatio)
return CGSize(width: finalWidth, height: displayHeight)
return CGSize(width: max(finalWidth, 1), height: max(displayHeight, 1))
}
// MARK: - Placeholder
@@ -214,6 +218,7 @@ struct MessageImageView: View {
// MARK: - Download
private func loadFromCache() {
PerformanceLogger.shared.track("image.cacheLoad")
if let cached = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) {
image = cached
}

View File

@@ -3,71 +3,40 @@ import UIKit
// MARK: - ZoomableImagePage
/// Single page in the image gallery viewer with centroid-based zoom.
/// Android parity: `ZoomableImage` in `ImageViewerScreen.kt` pinch zoom to centroid,
/// double-tap to tap point, velocity-based dismiss, touch slop.
/// Single page in the image gallery viewer with UIKit-based gesture handling.
/// Android parity: `ZoomableImage` in `ImageViewerScreen.kt` centroid-based pinch zoom,
/// double-tap to tap point, velocity-based dismiss, axis locking, edge tap navigation.
struct ZoomableImagePage: View {
let attachmentId: String
let onDismiss: () -> Void
let onDismissProgress: (CGFloat) -> Void
let onDismissCancel: () -> Void
@Binding var showControls: Bool
@Binding var currentScale: CGFloat
let onEdgeTap: ((Int) -> Void)?
@State private var image: UIImage?
@State private var scale: CGFloat = 1.0
@State private var offset: CGSize = .zero
@State private var dismissOffset: CGFloat = 0
@State private var dismissStartTime: Date?
private let minScale: CGFloat = 1.0
private let maxScale: CGFloat = 5.0
private let doubleTapScale: CGFloat = 2.5
private let dismissDistanceThreshold: CGFloat = 100
private let dismissVelocityThreshold: CGFloat = 800
private let touchSlop: CGFloat = 20
var body: some View {
GeometryReader { geometry in
ZStack {
// Background fade during dismiss
Color.black
.opacity(backgroundOpacity)
.ignoresSafeArea()
if let image {
imageContent(image, in: geometry)
} else {
placeholder
}
Group {
if let image {
ZoomableImageUIViewRepresentable(
image: image,
onDismiss: onDismiss,
onDismissProgress: onDismissProgress,
onDismissCancel: onDismissCancel,
onToggleControls: { showControls.toggle() },
onScaleChanged: { scale in currentScale = scale },
onEdgeTap: onEdgeTap
)
} else {
placeholder
}
}
.task {
image = AttachmentCache.shared.loadImage(forAttachmentId: attachmentId)
}
.onChange(of: scale) { _, newValue in
currentScale = newValue
}
}
// MARK: - Image Content
@ViewBuilder
private func imageContent(_ image: UIImage, in geometry: GeometryProxy) -> some View {
let size = geometry.size
Image(uiImage: image)
.resizable()
.scaledToFit()
.scaleEffect(scale)
.offset(x: offset.width, y: offset.height + dismissOffset)
.gesture(doubleTapGesture(in: size))
.gesture(pinchGesture(in: size))
.gesture(dragGesture(in: size))
.onTapGesture {
withAnimation(.easeInOut(duration: 0.2)) {
showControls.toggle()
}
}
}
// MARK: - Placeholder
@@ -81,118 +50,393 @@ struct ZoomableImagePage: View {
.foregroundStyle(.white.opacity(0.5))
}
}
}
// MARK: - Background Opacity
// MARK: - UIViewRepresentable
private var backgroundOpacity: Double {
let progress = min(abs(dismissOffset) / 300, 1.0)
return 1.0 - progress * 0.6
/// Wraps `ImageGestureContainerView` for SwiftUI integration.
private struct ZoomableImageUIViewRepresentable: UIViewRepresentable {
let image: UIImage
let onDismiss: () -> Void
let onDismissProgress: (CGFloat) -> Void
let onDismissCancel: () -> Void
let onToggleControls: () -> Void
let onScaleChanged: (CGFloat) -> Void
let onEdgeTap: ((Int) -> Void)?
func makeUIView(context: Context) -> ImageGestureContainerView {
let view = ImageGestureContainerView(image: image)
view.onDismiss = onDismiss
view.onDismissProgress = onDismissProgress
view.onDismissCancel = onDismissCancel
view.onToggleControls = onToggleControls
view.onScaleChanged = onScaleChanged
view.onEdgeTap = onEdgeTap
return view
}
// MARK: - Double Tap (zoom to tap point)
func updateUIView(_ view: ImageGestureContainerView, context: Context) {
view.onDismiss = onDismiss
view.onDismissProgress = onDismissProgress
view.onDismissCancel = onDismissCancel
view.onToggleControls = onToggleControls
view.onScaleChanged = onScaleChanged
view.onEdgeTap = onEdgeTap
}
}
private func doubleTapGesture(in size: CGSize) -> some Gesture {
SpatialTapGesture(count: 2)
.onEnded { value in
withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) {
if scale > 1.05 {
// Zoom out to 1x
scale = 1.0
offset = .zero
} else {
// Zoom in to tap point
let tapPoint = value.location
let viewCenter = CGPoint(x: size.width / 2, y: size.height / 2)
scale = doubleTapScale
// Shift image so tap point ends up at screen center
offset = CGSize(
width: (viewCenter.x - tapPoint.x) * (doubleTapScale - 1),
height: (viewCenter.y - tapPoint.y) * (doubleTapScale - 1)
)
// MARK: - ImageGestureContainerView
/// UIKit view that handles all image gestures with full control:
/// - Centroid-based pinch zoom (1x5x)
/// - Double-tap to zoom to tap point (2.5x) or reset
/// - Pan when zoomed (with offset clamping)
/// - Vertical drag to dismiss with velocity tracking
/// - Single tap: edge zones navigate, center toggles controls
/// - Axis locking: decides vertical dismiss vs pan early
///
/// Android parity: `ZoomableImage` in `ImageViewerScreen.kt`
final class ImageGestureContainerView: UIView, UIGestureRecognizerDelegate {
// MARK: - Configuration
private let minScale: CGFloat = 1.0
private let maxScale: CGFloat = 5.0
private let doubleTapScale: CGFloat = 2.5
private let dismissDistanceThreshold: CGFloat = 100
private let dismissVelocityThreshold: CGFloat = 500
private let touchSlop: CGFloat = 20
/// Android: left/right 20% zones are edge-tap navigation areas.
private let edgeTapFraction: CGFloat = 0.20
/// Android: spring(dampingRatio = 0.9, stiffness = 400) UIKit(damping: 0.9, velocity: 0)
private let springDamping: CGFloat = 0.9
private let springDuration: CGFloat = 0.35
// MARK: - Subviews
private let imageView = UIImageView()
// MARK: - Transform State
private var currentScale: CGFloat = 1.0
private var currentOffset: CGPoint = .zero
private var dismissOffset: CGFloat = 0
// Pinch gesture tracking
private var pinchStartScale: CGFloat = 1.0
private var pinchStartOffset: CGPoint = .zero
private var lastPinchCentroid: CGPoint = .zero
// Pan gesture tracking
private var panStartOffset: CGPoint = .zero
private var isDismissGesture = false
private var gestureAxisLocked = false
// MARK: - Callbacks
var onDismiss: (() -> Void)?
var onDismissProgress: ((CGFloat) -> Void)?
var onDismissCancel: (() -> Void)?
var onToggleControls: (() -> Void)?
var onScaleChanged: ((CGFloat) -> Void)?
/// -1 = left edge, 1 = right edge
var onEdgeTap: ((Int) -> Void)?
// MARK: - Init
init(image: UIImage) {
super.init(frame: .zero)
imageView.image = image
imageView.contentMode = .scaleAspectFit
imageView.isUserInteractionEnabled = false
addSubview(imageView)
clipsToBounds = true
setupGestures()
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
// MARK: - Layout
/// Track the last laid-out size so we only reset frame when it actually changes.
/// Without this, SwiftUI state changes (e.g. `onDismissProgress`) trigger
/// `layoutSubviews` `imageView.frame = bounds` which RESETS the UIKit transform,
/// causing the image to snap back during dismiss drag.
private var lastLayoutSize: CGSize = .zero
override func layoutSubviews() {
super.layoutSubviews()
guard lastLayoutSize != bounds.size else { return }
lastLayoutSize = bounds.size
// Temporarily reset transform, update frame, then re-apply.
let savedTransform = imageView.transform
imageView.transform = .identity
imageView.frame = bounds
imageView.transform = savedTransform
}
// MARK: - Gesture Setup
private func setupGestures() {
let pinch = UIPinchGestureRecognizer(target: self, action: #selector(handlePinch))
pinch.delegate = self
addGestureRecognizer(pinch)
let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan))
pan.delegate = self
pan.maximumNumberOfTouches = 1
addGestureRecognizer(pan)
let doubleTap = UITapGestureRecognizer(target: self, action: #selector(handleDoubleTap))
doubleTap.numberOfTapsRequired = 2
addGestureRecognizer(doubleTap)
let singleTap = UITapGestureRecognizer(target: self, action: #selector(handleSingleTap))
singleTap.numberOfTapsRequired = 1
singleTap.require(toFail: doubleTap)
addGestureRecognizer(singleTap)
}
// MARK: - Apply Transform
private func applyTransform(animated: Bool = false) {
// Guard against NaN/Infinity prevents CoreGraphics crash and UI freeze.
if currentScale.isNaN || currentScale.isInfinite { currentScale = 1.0 }
if currentOffset.x.isNaN || currentOffset.x.isInfinite { currentOffset.x = 0 }
if currentOffset.y.isNaN || currentOffset.y.isInfinite { currentOffset.y = 0 }
if dismissOffset.isNaN || dismissOffset.isInfinite { dismissOffset = 0 }
let transform = CGAffineTransform.identity
.translatedBy(x: currentOffset.x, y: currentOffset.y + dismissOffset)
.scaledBy(x: currentScale, y: currentScale)
if animated {
UIView.animate(
withDuration: springDuration,
delay: 0,
usingSpringWithDamping: springDamping,
initialSpringVelocity: 0,
options: [.curveEaseOut]
) {
self.imageView.transform = transform
}
} else {
imageView.transform = transform
}
}
// MARK: - Pinch Gesture (Centroid Zoom)
@objc private func handlePinch(_ gesture: UIPinchGestureRecognizer) {
switch gesture.state {
case .began:
pinchStartScale = currentScale
pinchStartOffset = currentOffset
if gesture.numberOfTouches >= 2 {
lastPinchCentroid = gesture.location(in: self)
}
case .changed:
let newScale = min(max(pinchStartScale * gesture.scale, minScale * 0.5), maxScale)
// Centroid-based zoom: keep the point under fingers stationary
if gesture.numberOfTouches >= 2 {
let centroid = gesture.location(in: self)
let viewCenter = CGPoint(x: bounds.midX, y: bounds.midY)
let gesturePoint = CGPoint(x: centroid.x - viewCenter.x, y: centroid.y - viewCenter.y)
let safeCurrentScale = max(currentScale, 0.01)
let scaleRatio = newScale / safeCurrentScale
guard scaleRatio.isFinite else { break }
currentOffset = CGPoint(
x: gesturePoint.x - (gesturePoint.x - currentOffset.x) * scaleRatio,
y: gesturePoint.y - (gesturePoint.y - currentOffset.y) * scaleRatio
)
lastPinchCentroid = centroid
}
currentScale = newScale
onScaleChanged?(currentScale)
applyTransform()
case .ended, .cancelled:
if currentScale < minScale + 0.05 {
// Snap back to 1x
currentScale = minScale
currentOffset = .zero
onScaleChanged?(minScale)
applyTransform(animated: true)
} else {
clampOffset(animated: true)
}
default: break
}
}
// MARK: - Pan Gesture (Pan when zoomed, Dismiss when not)
@objc private func handlePan(_ gesture: UIPanGestureRecognizer) {
let translation = gesture.translation(in: self)
let velocity = gesture.velocity(in: self)
switch gesture.state {
case .began:
panStartOffset = currentOffset
gestureAxisLocked = false
isDismissGesture = false
case .changed:
if currentScale > 1.05 {
// Zoomed: pan the image
currentOffset = CGPoint(
x: panStartOffset.x + translation.x,
y: panStartOffset.y + translation.y
)
applyTransform()
} else {
// Not zoomed: detect axis
if !gestureAxisLocked {
let dx = abs(translation.x)
let dy = abs(translation.y)
// Android: abs(panChange.y) > abs(panChange.x) * 1.5
if dx > touchSlop || dy > touchSlop {
gestureAxisLocked = true
isDismissGesture = dy > dx * 1.2
}
}
}
}
// MARK: - Pinch Gesture (zoom to centroid)
private func pinchGesture(in size: CGSize) -> some Gesture {
MagnificationGesture()
.onChanged { value in
let newScale = min(max(value * (scale > 0.01 ? 1.0 : scale), minScale * 0.5), maxScale)
// MagnificationGesture doesn't provide centroid, so zoom to center.
// For true centroid zoom, we'd need UIKit gesture recognizers.
// This is acceptable most users don't notice centroid vs center on mobile.
scale = newScale
}
.onEnded { _ in
withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) {
if scale < minScale {
scale = minScale
offset = .zero
}
clampOffset(in: size)
if isDismissGesture {
dismissOffset = translation.y
let progress = min(abs(dismissOffset) / 300, 1.0)
onDismissProgress?(progress)
applyTransform()
}
}
}
// MARK: - Drag Gesture (pan when zoomed, dismiss when not)
private func dragGesture(in size: CGSize) -> some Gesture {
DragGesture(minimumDistance: touchSlop)
.onChanged { value in
if scale > 1.05 {
// Zoomed: pan image
offset = CGSize(
width: value.translation.width,
height: value.translation.height
)
case .ended, .cancelled:
if currentScale > 1.05 {
clampOffset(animated: true)
} else if isDismissGesture {
let velocityY = abs(velocity.y)
if abs(dismissOffset) > dismissDistanceThreshold || velocityY > dismissVelocityThreshold {
// Dismiss with fade-out (Android: smoothDismiss 200ms fade)
onDismiss?()
} else {
// Not zoomed: check if vertical dominant (dismiss) or horizontal (page swipe)
let dx = abs(value.translation.width)
let dy = abs(value.translation.height)
if dy > dx * 1.2 {
if dismissStartTime == nil {
dismissStartTime = Date()
}
dismissOffset = value.translation.height
}
// Snap back
dismissOffset = 0
onDismissCancel?()
applyTransform(animated: true)
}
}
.onEnded { value in
if scale > 1.05 {
withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) {
clampOffset(in: size)
}
} else {
// Calculate velocity for dismiss
let elapsed = dismissStartTime.map { Date().timeIntervalSince($0) } ?? 0.3
let velocityY = abs(dismissOffset) / max(elapsed, 0.01)
isDismissGesture = false
gestureAxisLocked = false
if abs(dismissOffset) > dismissDistanceThreshold || velocityY > dismissVelocityThreshold {
onDismiss()
} else {
withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) {
dismissOffset = 0
}
}
dismissStartTime = nil
}
}
default: break
}
}
// MARK: - Double Tap (Zoom to tap point)
@objc private func handleDoubleTap(_ gesture: UITapGestureRecognizer) {
let tapPoint = gesture.location(in: self)
let viewCenter = CGPoint(x: bounds.midX, y: bounds.midY)
if currentScale > 1.1 {
// Zoom out to 1x
currentScale = minScale
currentOffset = .zero
onScaleChanged?(minScale)
applyTransform(animated: true)
} else {
// Zoom in to tap point at 2.5x (Android: tapX - tapX * targetScale)
let tapX = tapPoint.x - viewCenter.x
let tapY = tapPoint.y - viewCenter.y
currentScale = doubleTapScale
currentOffset = CGPoint(
x: tapX - tapX * doubleTapScale,
y: tapY - tapY * doubleTapScale
)
clampOffsetImmediate()
onScaleChanged?(doubleTapScale)
applyTransform(animated: true)
}
}
// MARK: - Single Tap (Edge navigation or toggle controls)
@objc private func handleSingleTap(_ gesture: UITapGestureRecognizer) {
guard currentScale <= 1.05 else {
// When zoomed, single tap always toggles controls
onToggleControls?()
return
}
let tapX = gesture.location(in: self).x
let width = bounds.width
let edgeZone = width * edgeTapFraction
if tapX < edgeZone {
onEdgeTap?(-1) // Previous
} else if tapX > width - edgeZone {
onEdgeTap?(1) // Next
} else {
onToggleControls?()
}
}
// MARK: - Offset Clamping
private func clampOffset(in size: CGSize) {
guard scale > 1.0 else {
offset = .zero
private func clampOffset(animated: Bool) {
guard currentScale > 1.0 else {
currentOffset = .zero
applyTransform(animated: animated)
return
}
let maxOffsetX = size.width * (scale - 1) / 2
let maxOffsetY = size.height * (scale - 1) / 2
offset = CGSize(
width: min(max(offset.width, -maxOffsetX), maxOffsetX),
height: min(max(offset.height, -maxOffsetY), maxOffsetY)
let clamped = clampedOffset()
if currentOffset != clamped {
currentOffset = clamped
applyTransform(animated: animated)
}
}
private func clampOffsetImmediate() {
currentOffset = clampedOffset()
}
private func clampedOffset() -> CGPoint {
let maxX = max(bounds.width * (currentScale - 1) / 2, 0)
let maxY = max(bounds.height * (currentScale - 1) / 2, 0)
return CGPoint(
x: min(max(currentOffset.x, -maxX), maxX),
y: min(max(currentOffset.y, -maxY), maxY)
)
}
// MARK: - UIGestureRecognizerDelegate
func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer
) -> Bool {
// Allow pinch + pan simultaneously (zoom + drag)
let isPinchPan = (gestureRecognizer is UIPinchGestureRecognizer && other is UIPanGestureRecognizer) ||
(gestureRecognizer is UIPanGestureRecognizer && other is UIPinchGestureRecognizer)
return isPinchPan
}
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
guard let pan = gestureRecognizer as? UIPanGestureRecognizer else { return true }
let velocity = pan.velocity(in: self)
if currentScale <= 1.05 {
// Not zoomed: only begin for vertical-dominant drags.
// Let horizontal swipes pass through to TabView for paging.
return abs(velocity.y) > abs(velocity.x) * 1.2
}
return true
}
}

View File

@@ -564,6 +564,7 @@ private struct ChatListDialogContent: View {
@State private var typingDialogs: Set<String> = []
var body: some View {
let _ = PerformanceLogger.shared.track("chatList.bodyEval")
// Use pre-partitioned arrays from ViewModel (single-pass O(n) instead of 3× filter).
let pinned = viewModel.allModePinned
let unpinned = viewModel.allModeUnpinned

View File

@@ -34,6 +34,7 @@ struct ChatRowView: View {
}
var body: some View {
let _ = PerformanceLogger.shared.track("chatRow.bodyEval")
HStack(spacing: 0) {
avatarSection
.padding(.trailing, 10)

View File

@@ -31,6 +31,7 @@ struct MainTabView: View {
}
var body: some View {
let _ = PerformanceLogger.shared.track("mainTab.bodyEval")
ZStack {
Group {
if #available(iOS 26.0, *) {
@@ -57,6 +58,11 @@ struct MainTabView: View {
// Full-screen device verification overlay (observation-isolated).
// Covers nav bar, search bar, and tab bar desktop parity.
DeviceConfirmOverlay()
// PERF: observation-isolated unread counter.
// Reads DialogRepository in its own scope MainTabView.body
// never observes the dialogs dictionary directly.
UnreadCountObserver(count: $cachedUnreadCount)
}
}
@@ -79,7 +85,7 @@ struct MainTabView: View {
Label(RosettaTab.chats.label, systemImage: RosettaTab.chats.icon)
}
.tag(RosettaTab.chats)
.badge(chatUnreadCount)
.badge(cachedUnreadCount)
SettingsView(onLogout: onLogout, onAddAccount: handleAddAccount, isEditingProfile: $isSettingsEditPresented, isDetailPresented: $isSettingsDetailPresented)
.tabItem {
@@ -195,20 +201,39 @@ struct MainTabView: View {
return [TabBadge(tab: .chats, text: chatUnreadBadge)]
}
/// Int badge for iOS 26+ TabView `.badge(0)` shows nothing,
/// and being non-conditional preserves ChatListView's structural identity.
private var chatUnreadCount: Int {
DialogRepository.shared.sortedDialogs
.filter { !$0.isMuted }
.reduce(0) { $0 + $1.unreadCount }
}
/// PERF: cached unread count updated by observation-isolated child view.
/// Reading DialogRepository.shared.sortedDialogs directly in MainTabView.body
/// creates observation on the entire dialogs dictionary, causing full body
/// re-evaluation on every dialog mutation (online, typing, delivery).
@State private var cachedUnreadCount: Int = 0
private var chatUnreadBadge: String? {
let unread = chatUnreadCount
if unread <= 0 {
if cachedUnreadCount <= 0 {
return nil
}
return unread > 999 ? "\(unread / 1000)K" : "\(unread)"
return cachedUnreadCount > 999 ? "\(cachedUnreadCount / 1000)K" : "\(cachedUnreadCount)"
}
}
// MARK: - Observation-Isolated Unread Counter
/// Invisible child view that reads DialogRepository in its OWN observation scope.
/// MainTabView.body never observes DialogRepository directly only this tiny view
/// re-evaluates when dialogs change, updating the parent via @Binding.
private struct UnreadCountObserver: View {
@Binding var count: Int
private var unreadTotal: Int {
DialogRepository.shared.sortedDialogs
.reduce(0) { $0 + ($1.isMuted ? 0 : $1.unreadCount) }
}
var body: some View {
let total = unreadTotal
Color.clear
.frame(width: 0, height: 0)
.allowsHitTesting(false)
.task(id: total) { count = total }
}
}

View File

@@ -358,6 +358,10 @@ struct SettingsView: View {
themeCard
safetyCard
#if DEBUG
debugCard
#endif
rosettaPowerFooter
}
.padding(.horizontal, 16)
@@ -773,6 +777,29 @@ struct SettingsView: View {
}
}
// MARK: - Debug (stress test)
#if DEBUG
private var debugCard: some View {
SettingsCard {
Button {
StressTestGenerator.generateMessages(count: 100, dialogKey: "stress_test_\(Int.random(in: 1000...9999))")
} label: {
HStack {
Spacer()
Text("🧪 Generate 100 Messages + Photos")
.font(.system(size: 17))
.foregroundStyle(.orange)
Spacer()
}
.frame(height: 52)
.contentShape(Rectangle())
}
.settingsHighlight()
}
}
#endif
// MARK: - Helpers
/// Figma row: 52pt height, 30×30 rounded icon (r7), 23pt symbol, Medium 17pt title.