Оптимизация FPS чата: ForEach fast path, keyboard animation без updateUIView, debounce pipeline, кэши с half-eviction, release notes механизм
This commit is contained in:
@@ -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 */
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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.
|
||||
|
||||
256
Rosetta/Core/Utils/PerformanceLogger.swift
Normal file
256
Rosetta/Core/Utils/PerformanceLogger.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
157
Rosetta/Core/Utils/StressTestGenerator.swift
Normal file
157
Rosetta/Core/Utils/StressTestGenerator.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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 0→0 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 (1x–5x)
|
||||
/// - 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -34,6 +34,7 @@ struct ChatRowView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let _ = PerformanceLogger.shared.track("chatRow.bodyEval")
|
||||
HStack(spacing: 0) {
|
||||
avatarSection
|
||||
.padding(.trailing, 10)
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user