Оптимизация 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 */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
853F29642F4B50410092AD05 /* Rosetta */ = {
|
853F29642F4B50410092AD05 /* Rosetta */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
exceptions = (
|
|
||||||
);
|
|
||||||
path = Rosetta;
|
path = Rosetta;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -124,7 +122,6 @@
|
|||||||
0F43A41D5496A62870E307FC /* NotificationService.swift */,
|
0F43A41D5496A62870E307FC /* NotificationService.swift */,
|
||||||
93685A4F330DCD1B63EF121F /* Info.plist */,
|
93685A4F330DCD1B63EF121F /* Info.plist */,
|
||||||
);
|
);
|
||||||
name = RosettaNotificationService;
|
|
||||||
path = RosettaNotificationService;
|
path = RosettaNotificationService;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -420,7 +417,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 19;
|
CURRENT_PROJECT_VERSION = 20;
|
||||||
DEVELOPMENT_TEAM = QN8Z263QGX;
|
DEVELOPMENT_TEAM = QN8Z263QGX;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -436,7 +433,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.1.8;
|
MARKETING_VERSION = 1.1.9;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
|
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
@@ -459,7 +456,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 19;
|
CURRENT_PROJECT_VERSION = 20;
|
||||||
DEVELOPMENT_TEAM = QN8Z263QGX;
|
DEVELOPMENT_TEAM = QN8Z263QGX;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -475,7 +472,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.1.8;
|
MARKETING_VERSION = 1.1.9;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
|
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
@@ -529,7 +526,7 @@
|
|||||||
853F296C2F4B50420092AD05 /* Release */,
|
853F296C2F4B50420092AD05 /* Release */,
|
||||||
);
|
);
|
||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Debug;
|
||||||
};
|
};
|
||||||
853F296D2F4B50420092AD05 /* Build configuration list for PBXNativeTarget "Rosetta" */ = {
|
853F296D2F4B50420092AD05 /* Build configuration list for PBXNativeTarget "Rosetta" */ = {
|
||||||
isa = XCConfigurationList;
|
isa = XCConfigurationList;
|
||||||
@@ -538,7 +535,7 @@
|
|||||||
853F296F2F4B50420092AD05 /* Release */,
|
853F296F2F4B50420092AD05 /* Release */,
|
||||||
);
|
);
|
||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Debug;
|
||||||
};
|
};
|
||||||
B5D2E60ADEB8AE2E8F7615C6 /* Build configuration list for PBXNativeTarget "RosettaNotificationService" */ = {
|
B5D2E60ADEB8AE2E8F7615C6 /* Build configuration list for PBXNativeTarget "RosettaNotificationService" */ = {
|
||||||
isa = XCConfigurationList;
|
isa = XCConfigurationList;
|
||||||
@@ -547,7 +544,7 @@
|
|||||||
0140D6320A9CF4B5E933E0B1 /* Debug */,
|
0140D6320A9CF4B5E933E0B1 /* Debug */,
|
||||||
);
|
);
|
||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Debug;
|
||||||
};
|
};
|
||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
shouldAutocreateTestPlan = "YES">
|
shouldAutocreateTestPlan = "YES">
|
||||||
</TestAction>
|
</TestAction>
|
||||||
<LaunchAction
|
<LaunchAction
|
||||||
buildConfiguration = "Release"
|
buildConfiguration = "Debug"
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
launchStyle = "0"
|
launchStyle = "0"
|
||||||
@@ -50,6 +50,19 @@
|
|||||||
ReferencedContainer = "container:Rosetta.xcodeproj">
|
ReferencedContainer = "container:Rosetta.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
|
<CommandLineArguments>
|
||||||
|
<CommandLineArgument
|
||||||
|
argument = "-LogForEachSlowPath YES"
|
||||||
|
isEnabled = "YES">
|
||||||
|
</CommandLineArgument>
|
||||||
|
</CommandLineArguments>
|
||||||
|
<EnvironmentVariables>
|
||||||
|
<EnvironmentVariable
|
||||||
|
key = "CA_DEBUG_TRANSACTIONS"
|
||||||
|
value = "1"
|
||||||
|
isEnabled = "YES">
|
||||||
|
</EnvironmentVariable>
|
||||||
|
</EnvironmentVariables>
|
||||||
</LaunchAction>
|
</LaunchAction>
|
||||||
<ProfileAction
|
<ProfileAction
|
||||||
buildConfiguration = "Release"
|
buildConfiguration = "Release"
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ final class AttachmentCache: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Loads a cached image for an attachment ID, or `nil` if not cached.
|
/// Loads a cached image for an attachment ID, or `nil` if not cached.
|
||||||
|
/// Uses simple UIImage loading — downsampling moved to background in MessageImageView.
|
||||||
func loadImage(forAttachmentId id: String) -> UIImage? {
|
func loadImage(forAttachmentId id: String) -> UIImage? {
|
||||||
let url = cacheDir.appendingPathComponent("img_\(id).jpg")
|
let url = cacheDir.appendingPathComponent("img_\(id).jpg")
|
||||||
guard FileManager.default.fileExists(atPath: url.path) else { return nil }
|
guard FileManager.default.fileExists(atPath: url.path) else { return nil }
|
||||||
|
|||||||
@@ -247,6 +247,7 @@ final class DialogRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func updateOnlineState(publicKey: String, isOnline: Bool) {
|
func updateOnlineState(publicKey: String, isOnline: Bool) {
|
||||||
|
PerformanceLogger.shared.track("dialog.updateOnline")
|
||||||
guard var dialog = dialogs[publicKey] else { return }
|
guard var dialog = dialogs[publicKey] else { return }
|
||||||
guard dialog.isOnline != isOnline else { return }
|
guard dialog.isOnline != isOnline else { return }
|
||||||
dialog.isOnline = isOnline
|
dialog.isOnline = isOnline
|
||||||
@@ -279,6 +280,7 @@ final class DialogRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func updateUserInfo(publicKey: String, title: String, username: String, verified: Int = 0, online: Int = -1) {
|
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 }
|
guard var dialog = dialogs[publicKey] else { return }
|
||||||
var changed = false
|
var changed = false
|
||||||
if !title.isEmpty, dialog.opponentTitle != title {
|
if !title.isEmpty, dialog.opponentTitle != title {
|
||||||
@@ -484,8 +486,9 @@ final class DialogRepository {
|
|||||||
|
|
||||||
private func schedulePersist() {
|
private func schedulePersist() {
|
||||||
guard !currentAccount.isEmpty else { return }
|
guard !currentAccount.isEmpty else { return }
|
||||||
|
PerformanceLogger.shared.track("dialog.schedulePersist")
|
||||||
|
|
||||||
updateAppBadge()
|
scheduleAppBadgeUpdate()
|
||||||
|
|
||||||
let snapshot = Array(dialogs.values)
|
let snapshot = Array(dialogs.values)
|
||||||
let fileName = Self.dialogsFileName(for: currentAccount)
|
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.
|
/// Update app icon badge with total unread message count.
|
||||||
/// Writes to shared App Group UserDefaults so the Notification Service Extension
|
/// Writes to shared App Group UserDefaults so the Notification Service Extension
|
||||||
/// can read the current count and increment it when the app is terminated.
|
/// can read the current count and increment it when the app is terminated.
|
||||||
private func updateAppBadge() {
|
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)
|
UNUserNotificationCenter.current().setBadgeCount(total)
|
||||||
// Shared storage — NSE reads this to increment badge when app is killed.
|
// Shared storage — NSE reads this to increment badge when app is killed.
|
||||||
let shared = UserDefaults(suiteName: "group.com.rosetta.dev")
|
let shared = UserDefaults(suiteName: "group.com.rosetta.dev")
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ final class MessageRepository: ObservableObject {
|
|||||||
attachmentPassword: String? = nil,
|
attachmentPassword: String? = nil,
|
||||||
fromSync: Bool = false
|
fromSync: Bool = false
|
||||||
) {
|
) {
|
||||||
|
PerformanceLogger.shared.track("message.upsert")
|
||||||
let fromMe = packet.fromPublicKey == myPublicKey
|
let fromMe = packet.fromPublicKey == myPublicKey
|
||||||
let dialogKey = fromMe ? packet.toPublicKey : packet.fromPublicKey
|
let dialogKey = fromMe ? packet.toPublicKey : packet.fromPublicKey
|
||||||
let messageId = packet.messageId.isEmpty ? UUID().uuidString : packet.messageId
|
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) {
|
func updateDeliveryStatus(messageId: String, status: DeliveryStatus, newTimestamp: Int64? = nil) {
|
||||||
|
PerformanceLogger.shared.track("message.deliveryUpdate")
|
||||||
guard let dialogKey = messageToDialog[messageId] else { return }
|
guard let dialogKey = messageToDialog[messageId] else { return }
|
||||||
updateMessages(for: dialogKey) { messages in
|
updateMessages(for: dialogKey) { messages in
|
||||||
guard let index = messages.firstIndex(where: { $0.id == messageId }) else { return }
|
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
|
// MARK: - Private
|
||||||
|
|
||||||
private func updateMessages(for dialogKey: String, mutate: (inout [ChatMessage]) -> Void) {
|
private func updateMessages(for dialogKey: String, mutate: (inout [ChatMessage]) -> Void) {
|
||||||
@@ -400,6 +411,7 @@ final class MessageRepository: ObservableObject {
|
|||||||
|
|
||||||
private func schedulePersist() {
|
private func schedulePersist() {
|
||||||
guard !currentAccount.isEmpty else { return }
|
guard !currentAccount.isEmpty else { return }
|
||||||
|
PerformanceLogger.shared.track("message.schedulePersist")
|
||||||
|
|
||||||
let snapshot = messagesByDialog
|
let snapshot = messagesByDialog
|
||||||
let idsSnapshot = allKnownMessageIds
|
let idsSnapshot = allKnownMessageIds
|
||||||
@@ -407,9 +419,11 @@ final class MessageRepository: ObservableObject {
|
|||||||
let knownIdsFile = Self.knownIdsFileName(for: currentAccount)
|
let knownIdsFile = Self.knownIdsFileName(for: currentAccount)
|
||||||
let storagePassword = self.storagePassword
|
let storagePassword = self.storagePassword
|
||||||
let password = storagePassword.isEmpty ? nil : 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 isSyncing = SessionManager.shared.syncBatchInProgress
|
||||||
let debounceMs = isSyncing ? 2000 : 400
|
let debounceMs = isSyncing ? 3000 : 800
|
||||||
persistTask?.cancel()
|
persistTask?.cancel()
|
||||||
persistTask = Task(priority: .utility) {
|
persistTask = Task(priority: .utility) {
|
||||||
try? await Task.sleep(for: .milliseconds(debounceMs))
|
try? await Task.sleep(for: .milliseconds(debounceMs))
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ final class ProtocolManager: @unchecked Sendable {
|
|||||||
// MARK: - Sending
|
// MARK: - Sending
|
||||||
|
|
||||||
func sendPacket(_ packet: any Packet) {
|
func sendPacket(_ packet: any Packet) {
|
||||||
|
PerformanceLogger.shared.track("protocol.sendPacket")
|
||||||
let id = String(type(of: packet).packetId, radix: 16)
|
let id = String(type(of: packet).packetId, radix: 16)
|
||||||
if (!handshakeComplete && !(packet is PacketHandshake)) || !client.isConnected {
|
if (!handshakeComplete && !(packet is PacketHandshake)) || !client.isConnected {
|
||||||
Self.logger.info("⏳ Queueing packet 0x\(id) — connected=\(self.client.isConnected), handshake=\(self.handshakeComplete)")
|
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.
|
/// Sends an encrypted message to a recipient, matching Android's outgoing flow.
|
||||||
func sendMessage(text: String, toPublicKey: String, opponentTitle: String = "", opponentUsername: String = "") async throws {
|
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.
|
// Desktop parity: validate message is not empty/whitespace-only before sending.
|
||||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !trimmed.isEmpty else {
|
guard !trimmed.isEmpty else {
|
||||||
@@ -1122,6 +1123,7 @@ final class SessionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func processIncomingMessage(_ packet: PacketMessage) async {
|
private func processIncomingMessage(_ packet: PacketMessage) async {
|
||||||
|
PerformanceLogger.shared.track("session.processIncoming")
|
||||||
let myKey = currentPublicKey
|
let myKey = currentPublicKey
|
||||||
let currentPrivateKeyHex = self.privateKeyHex
|
let currentPrivateKeyHex = self.privateKeyHex
|
||||||
let currentPrivateKeyHash = self.privateKeyHash
|
let currentPrivateKeyHash = self.privateKeyHash
|
||||||
@@ -1534,6 +1536,9 @@ final class SessionManager {
|
|||||||
/// Request user info for all existing dialog opponents after sync completes.
|
/// Request user info for all existing dialog opponents after sync completes.
|
||||||
/// Desktop parity: useUserInformation sends PacketSearch(publicKey) lazily per-component.
|
/// Desktop parity: useUserInformation sends PacketSearch(publicKey) lazily per-component.
|
||||||
/// We do it in bulk after sync — with generous staggering to avoid server rate-limiting.
|
/// 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 {
|
private func refreshOnlineStatusForAllDialogs() async {
|
||||||
let dialogs = DialogRepository.shared.dialogs
|
let dialogs = DialogRepository.shared.dialogs
|
||||||
let ownKey = currentPublicKey
|
let ownKey = currentPublicKey
|
||||||
@@ -1554,6 +1559,7 @@ final class SessionManager {
|
|||||||
var count = 0
|
var count = 0
|
||||||
for key in missingName {
|
for key in missingName {
|
||||||
guard ProtocolManager.shared.connectionState == .authenticated else { break }
|
guard ProtocolManager.shared.connectionState == .authenticated else { break }
|
||||||
|
guard count < 30 else { break } // PERF: cap to prevent request storm
|
||||||
requestUserInfoIfNeeded(opponentKey: key, privateKeyHash: privateKeyHash)
|
requestUserInfoIfNeeded(opponentKey: key, privateKeyHash: privateKeyHash)
|
||||||
count += 1
|
count += 1
|
||||||
if count > 1 {
|
if count > 1 {
|
||||||
@@ -1561,8 +1567,18 @@ final class SessionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then refresh online status for dialogs that already have names (300ms stagger)
|
// Then refresh online status for recently active dialogs only (300ms stagger).
|
||||||
for key in hasName {
|
// 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 }
|
guard ProtocolManager.shared.connectionState == .authenticated else { break }
|
||||||
requestUserInfoIfNeeded(opponentKey: key, privateKeyHash: privateKeyHash)
|
requestUserInfoIfNeeded(opponentKey: key, privateKeyHash: privateKeyHash)
|
||||||
count += 1
|
count += 1
|
||||||
@@ -1570,7 +1586,7 @@ final class SessionManager {
|
|||||||
try? await Task.sleep(for: .milliseconds(300))
|
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.
|
/// 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
|
@Published private(set) var keyboardPadding: CGFloat = 0
|
||||||
|
|
||||||
private var isAnimating = false
|
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 let bottomInset: CGFloat
|
||||||
private var pendingResetTask: Task<Void, Never>?
|
private var pendingResetTask: Task<Void, Never>?
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
@@ -81,6 +84,7 @@ final class KeyboardTracker: ObservableObject {
|
|||||||
/// to reduce ChatDetailView.body evaluations during swipe-to-dismiss.
|
/// to reduce ChatDetailView.body evaluations during swipe-to-dismiss.
|
||||||
func updateFromKVO(keyboardHeight: CGFloat) {
|
func updateFromKVO(keyboardHeight: CGFloat) {
|
||||||
if #available(iOS 26, *) { return }
|
if #available(iOS 26, *) { return }
|
||||||
|
PerformanceLogger.shared.track("keyboard.kvo")
|
||||||
guard !isAnimating else { return }
|
guard !isAnimating else { return }
|
||||||
|
|
||||||
if keyboardHeight <= 0 {
|
if keyboardHeight <= 0 {
|
||||||
@@ -119,7 +123,7 @@ final class KeyboardTracker: ObservableObject {
|
|||||||
|
|
||||||
// Start coalescing display link if not running
|
// Start coalescing display link if not running
|
||||||
if kvoDisplayLink == nil {
|
if kvoDisplayLink == nil {
|
||||||
kvoDisplayLink = DisplayLinkProxy(preferredFPS: 30) { [weak self] in
|
kvoDisplayLink = DisplayLinkProxy(maxFPS: 30) { [weak self] in
|
||||||
self?.applyPendingKVO()
|
self?.applyPendingKVO()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -133,6 +137,8 @@ final class KeyboardTracker: ObservableObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
pendingKVOPadding = nil
|
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 }
|
guard pending != keyboardPadding else { return }
|
||||||
keyboardPadding = pending
|
keyboardPadding = pending
|
||||||
}
|
}
|
||||||
@@ -176,22 +182,18 @@ final class KeyboardTracker: ObservableObject {
|
|||||||
let delta = targetPadding - lastNotificationPadding
|
let delta = targetPadding - lastNotificationPadding
|
||||||
lastNotificationPadding = targetPadding
|
lastNotificationPadding = targetPadding
|
||||||
|
|
||||||
// Guard: skip animation when target equals current padding (e.g., after
|
PerformanceLogger.shared.track("keyboard.notification")
|
||||||
// interactive dismiss already brought padding to 0, the late notification
|
// CADisplayLink at 30fps — smooth interpolation synced with keyboard curve.
|
||||||
// would start a wasted 0→0 animation with ~14 no-op CADisplayLink ticks).
|
// BubbleContextMenuOverlay.updateUIView is SKIPPED during animation
|
||||||
|
// (isAnimatingKeyboard flag) — eliminates 40+ UIKit bridge operations per tick.
|
||||||
if abs(delta) > 1, targetPadding != keyboardPadding {
|
if abs(delta) > 1, targetPadding != keyboardPadding {
|
||||||
// CADisplayLink interpolation: updates @Published at ~60fps.
|
isAnimatingKeyboard = true
|
||||||
// Each frame is a small layout delta → LazyVStack handles it without
|
|
||||||
// cell recycling → no gaps between message bubbles.
|
|
||||||
startPaddingAnimation(to: targetPadding, duration: duration, curveRaw: curveRaw)
|
startPaddingAnimation(to: targetPadding, duration: duration, curveRaw: curveRaw)
|
||||||
} else {
|
} else if keyboardPadding != targetPadding {
|
||||||
// Still snap to target in case of rounding differences
|
|
||||||
if keyboardPadding != targetPadding {
|
|
||||||
keyboardPadding = targetPadding
|
keyboardPadding = targetPadding
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Unblock KVO after animation + buffer.
|
// Unblock KVO after keyboard settles.
|
||||||
let unblockDelay = max(duration, 0.05) + 0.15
|
let unblockDelay = max(duration, 0.05) + 0.15
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
try? await Task.sleep(for: .seconds(unblockDelay))
|
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) {
|
private func startPaddingAnimation(to target: CGFloat, duration: CFTimeInterval, curveRaw: Int) {
|
||||||
animationNumber += 1
|
animationNumber += 1
|
||||||
@@ -222,6 +261,8 @@ final class KeyboardTracker: ObservableObject {
|
|||||||
// Reuse existing display link to preserve vsync phase alignment.
|
// Reuse existing display link to preserve vsync phase alignment.
|
||||||
// Creating a new CADisplayLink on each animation resets the phase,
|
// Creating a new CADisplayLink on each animation resets the phase,
|
||||||
// causing alternating frame intervals (15/18ms instead of steady 16.6ms).
|
// causing alternating frame intervals (15/18ms instead of steady 16.6ms).
|
||||||
|
// No FPS cap — runs at device native rate (120Hz ProMotion, 60Hz standard).
|
||||||
|
// BubbleContextMenuOverlay.updateUIView is a no-op, so per-tick cost is trivial.
|
||||||
if let proxy = displayLinkProxy {
|
if let proxy = displayLinkProxy {
|
||||||
proxy.isPaused = false
|
proxy.isPaused = false
|
||||||
} else {
|
} else {
|
||||||
@@ -267,6 +308,8 @@ final class KeyboardTracker: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func animationTick() {
|
private func animationTick() {
|
||||||
|
let tickStart = CACurrentMediaTime()
|
||||||
|
PerformanceLogger.shared.track("keyboard.animTick")
|
||||||
animTickCount += 1
|
animTickCount += 1
|
||||||
|
|
||||||
let now = CACurrentMediaTime()
|
let now = CACurrentMediaTime()
|
||||||
@@ -289,19 +332,37 @@ final class KeyboardTracker: ObservableObject {
|
|||||||
isComplete = t >= 1.0
|
isComplete = t >= 1.0
|
||||||
}
|
}
|
||||||
|
|
||||||
// Round to nearest 1pt — sub-point changes are invisible but still
|
// Guard: presentation layer can return NaN opacity during edge cases
|
||||||
// trigger full SwiftUI layout passes. Skipping them reduces render cost.
|
// (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 raw = animStartPadding + (animTargetPadding - animStartPadding) * eased
|
||||||
let rounded = round(raw)
|
let rounded = max(0, round(raw))
|
||||||
|
|
||||||
if isComplete || animTickCount > 30 {
|
if isComplete || animTickCount > 30 {
|
||||||
keyboardPadding = animTargetPadding
|
keyboardPadding = max(0, animTargetPadding)
|
||||||
// Pause instead of invalidate — preserves vsync phase for next animation.
|
// Pause instead of invalidate — preserves vsync phase for next animation.
|
||||||
displayLinkProxy?.isPaused = true
|
displayLinkProxy?.isPaused = true
|
||||||
lastTickTime = 0
|
lastTickTime = 0
|
||||||
|
isAnimatingKeyboard = false
|
||||||
} else if rounded != keyboardPadding {
|
} else if rounded != keyboardPadding {
|
||||||
keyboardPadding = rounded
|
keyboardPadding = rounded
|
||||||
}
|
}
|
||||||
|
#if DEBUG
|
||||||
|
let tickMs = (CACurrentMediaTime() - tickStart) * 1000
|
||||||
|
if tickMs > 16 {
|
||||||
|
PerformanceLogger.shared.track("keyboard.slowTick")
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Cubic bezier fallback
|
// MARK: - Cubic bezier fallback
|
||||||
@@ -360,15 +421,18 @@ private class DisplayLinkProxy {
|
|||||||
private var callback: (() -> Void)?
|
private var callback: (() -> Void)?
|
||||||
private var displayLink: CADisplayLink?
|
private var displayLink: CADisplayLink?
|
||||||
|
|
||||||
/// - Parameter preferredFPS: Target frame rate. 60 for notification animation,
|
/// - Parameter maxFPS: Max frame rate. 0 = device native (120Hz on ProMotion).
|
||||||
/// 30 for KVO coalescing (halves body evaluations during interactive dismiss).
|
/// Non-zero values cap via preferredFrameRateRange.
|
||||||
init(preferredFPS: Int = 60, callback: @escaping () -> Void) {
|
init(maxFPS: Int = 0, callback: @escaping () -> Void) {
|
||||||
self.callback = callback
|
self.callback = callback
|
||||||
self.displayLink = CADisplayLink(target: self, selector: #selector(tick))
|
self.displayLink = CADisplayLink(target: self, selector: #selector(tick))
|
||||||
let fps = Float(preferredFPS)
|
if maxFPS > 0 {
|
||||||
|
let fps = Float(maxFPS)
|
||||||
self.displayLink?.preferredFrameRateRange = CAFrameRateRange(
|
self.displayLink?.preferredFrameRateRange = CAFrameRateRange(
|
||||||
minimum: fps / 2, maximum: fps, preferred: fps
|
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)
|
self.displayLink?.add(to: .main, forMode: .common)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -344,6 +344,7 @@ struct AttachmentPanelView: View {
|
|||||||
.padding(4)
|
.padding(4)
|
||||||
.background { tabBarBackground }
|
.background { tabBarBackground }
|
||||||
.clipShape(Capsule())
|
.clipShape(Capsule())
|
||||||
|
.contentShape(Capsule())
|
||||||
.tabBarShadow()
|
.tabBarShadow()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,7 +357,13 @@ struct AttachmentPanelView: View {
|
|||||||
.fill(.clear)
|
.fill(.clear)
|
||||||
.glassEffect(.regular, in: .capsule)
|
.glassEffect(.regular, in: .capsule)
|
||||||
} else {
|
} 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)
|
.fill(.clear)
|
||||||
.glassEffect(.regular, in: .capsule)
|
.glassEffect(.regular, in: .capsule)
|
||||||
} else {
|
} 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) {
|
func updateUIView(_ uiView: UIView, context: Context) {
|
||||||
context.coordinator.actions = actions
|
// PERF: only update callbacks (lightweight pointer swap).
|
||||||
context.coordinator.previewShape = previewShape
|
// Skip actions/previewShape/readStatusText — these involve array allocation
|
||||||
context.coordinator.readStatusText = readStatusText
|
// 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.onTap = onTap
|
||||||
context.coordinator.replyQuoteHeight = replyQuoteHeight
|
|
||||||
context.coordinator.onReplyQuoteTap = onReplyQuoteTap
|
context.coordinator.onReplyQuoteTap = onReplyQuoteTap
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ private struct KeyboardSpacer: View {
|
|||||||
let composerHeight: CGFloat
|
let composerHeight: CGFloat
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
let _ = PerformanceLogger.shared.track("keyboardSpacer.bodyEval")
|
||||||
Color.clear.frame(height: composerHeight + keyboard.keyboardPadding + 4)
|
Color.clear.frame(height: composerHeight + keyboard.keyboardPadding + 4)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -34,6 +35,7 @@ private struct KeyboardPaddedView<Content: View>: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
let _ = PerformanceLogger.shared.track("keyboardPadded.bodyEval")
|
||||||
content.offset(y: -(keyboard.keyboardPadding + extraPadding))
|
content.offset(y: -(keyboard.keyboardPadding + extraPadding))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -87,8 +89,8 @@ struct ChatDetailView: View {
|
|||||||
@State private var showForwardPicker = false
|
@State private var showForwardPicker = false
|
||||||
@State private var forwardingMessage: ChatMessage?
|
@State private var forwardingMessage: ChatMessage?
|
||||||
@State private var messageToDelete: ChatMessage?
|
@State private var messageToDelete: ChatMessage?
|
||||||
/// State for the multi-photo gallery viewer (nil = dismissed).
|
// Image viewer is presented via ImageViewerPresenter (UIKit overFullScreen),
|
||||||
@State private var imageViewerState: ImageViewerState?
|
// not via SwiftUI fullScreenCover, to avoid bottom-sheet slide-up animation.
|
||||||
/// ID of message to scroll to (set when tapping a reply quote).
|
/// ID of message to scroll to (set when tapping a reply quote).
|
||||||
@State private var scrollToMessageId: String?
|
@State private var scrollToMessageId: String?
|
||||||
/// ID of message currently highlighted after scroll-to-reply navigation.
|
/// ID of message currently highlighted after scroll-to-reply navigation.
|
||||||
@@ -173,10 +175,13 @@ struct ChatDetailView: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var content: some View {
|
private var content: some View {
|
||||||
|
let _ = PerformanceLogger.shared.track("chatDetail.bodyEval")
|
||||||
ZStack {
|
ZStack {
|
||||||
messagesList(maxBubbleWidth: maxBubbleWidth)
|
messagesList(maxBubbleWidth: maxBubbleWidth)
|
||||||
}
|
}
|
||||||
.overlay { chatEdgeGradients }
|
.overlay { chatEdgeGradients }
|
||||||
|
// FPS overlay — uncomment for performance testing:
|
||||||
|
// .overlay { FPSOverlayView() }
|
||||||
.overlay(alignment: .bottom) {
|
.overlay(alignment: .bottom) {
|
||||||
if !route.isSystemAccount {
|
if !route.isSystemAccount {
|
||||||
KeyboardPaddedView {
|
KeyboardPaddedView {
|
||||||
@@ -286,17 +291,8 @@ struct ChatDetailView: View {
|
|||||||
forwardMessage(message, to: targetRoute)
|
forwardMessage(message, to: targetRoute)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.fullScreenCover(isPresented: Binding(
|
// Image viewer: presented via ImageViewerPresenter (UIKit overFullScreen + crossDissolve).
|
||||||
get: { imageViewerState != nil },
|
// No .fullScreenCover — avoids the default bottom-sheet slide-up animation.
|
||||||
set: { if !$0 { imageViewerState = nil } }
|
|
||||||
)) {
|
|
||||||
if let state = imageViewerState {
|
|
||||||
ImageGalleryViewer(
|
|
||||||
state: state,
|
|
||||||
onDismiss: { imageViewerState = nil }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.alert("Delete Message", isPresented: Binding(
|
.alert("Delete Message", isPresented: Binding(
|
||||||
get: { messageToDelete != nil },
|
get: { messageToDelete != nil },
|
||||||
set: { if !$0 { messageToDelete = nil } }
|
set: { if !$0 { messageToDelete = nil } }
|
||||||
@@ -688,14 +684,11 @@ private extension ChatDetailView {
|
|||||||
.frame(height: 4)
|
.frame(height: 4)
|
||||||
.id(Self.scrollBottomAnchorId)
|
.id(Self.scrollBottomAnchorId)
|
||||||
|
|
||||||
// Spacer for composer + keyboard — OUTSIDE LazyVStack so padding
|
// Spacer for composer + keyboard — OUTSIDE LazyVStack.
|
||||||
// changes only shift the LazyVStack as a whole block (cheap),
|
|
||||||
// instead of re-laying out every cell inside it (expensive).
|
|
||||||
// Isolated in KeyboardSpacer to avoid marking parent dirty.
|
// Isolated in KeyboardSpacer to avoid marking parent dirty.
|
||||||
KeyboardSpacer(composerHeight: composerHeight)
|
KeyboardSpacer(composerHeight: composerHeight)
|
||||||
|
|
||||||
// LazyVStack: only visible cells are loaded. Internal layout
|
// LazyVStack: only visible cells are loaded.
|
||||||
// is unaffected by the spacer above changing height.
|
|
||||||
LazyVStack(spacing: 0) {
|
LazyVStack(spacing: 0) {
|
||||||
// Sentinel for viewport-based scroll tracking.
|
// Sentinel for viewport-based scroll tracking.
|
||||||
// Must be inside LazyVStack — regular VStack doesn't
|
// Must be inside LazyVStack — regular VStack doesn't
|
||||||
@@ -707,7 +700,12 @@ private extension ChatDetailView {
|
|||||||
|
|
||||||
// PERF: iterate reversed messages directly, avoid Array(enumerated()) allocation.
|
// PERF: iterate reversed messages directly, avoid Array(enumerated()) allocation.
|
||||||
// Use message.id identity (stable) — integer indices shift on insert.
|
// 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
|
ForEach(messages.reversed()) { message in
|
||||||
|
VStack(spacing: 0) {
|
||||||
let index = messageIndex(for: message.id)
|
let index = messageIndex(for: message.id)
|
||||||
let position = bubblePosition(for: index)
|
let position = bubblePosition(for: index)
|
||||||
messageRow(
|
messageRow(
|
||||||
@@ -718,7 +716,6 @@ private extension ChatDetailView {
|
|||||||
.scaleEffect(x: 1, y: -1) // flip each row back to normal
|
.scaleEffect(x: 1, y: -1) // flip each row back to normal
|
||||||
|
|
||||||
// Unread Messages separator (Telegram style).
|
// Unread Messages separator (Telegram style).
|
||||||
// In inverted scroll, "above" visually = after in code.
|
|
||||||
if message.id == firstUnreadMessageId {
|
if message.id == firstUnreadMessageId {
|
||||||
unreadSeparator
|
unreadSeparator
|
||||||
.scaleEffect(x: 1, y: -1)
|
.scaleEffect(x: 1, y: -1)
|
||||||
@@ -726,6 +723,7 @@ private extension ChatDetailView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.padding(.horizontal, 10)
|
.padding(.horizontal, 10)
|
||||||
.padding(.bottom, messagesTopInset) // visual top (near nav bar)
|
.padding(.bottom, messagesTopInset) // visual top (near nav bar)
|
||||||
}
|
}
|
||||||
@@ -820,6 +818,7 @@ private extension ChatDetailView {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
func messageRow(_ message: ChatMessage, maxBubbleWidth: CGFloat, position: BubblePosition) -> some View {
|
func messageRow(_ message: ChatMessage, maxBubbleWidth: CGFloat, position: BubblePosition) -> some View {
|
||||||
|
let _ = PerformanceLogger.shared.track("chatDetail.rowEval")
|
||||||
let outgoing = message.isFromMe(myPublicKey: currentPublicKey)
|
let outgoing = message.isFromMe(myPublicKey: currentPublicKey)
|
||||||
let hasTail = position == .single || position == .bottom
|
let hasTail = position == .single || position == .bottom
|
||||||
|
|
||||||
@@ -974,14 +973,14 @@ private extension ChatDetailView {
|
|||||||
.padding(.top, 3)
|
.padding(.top, 3)
|
||||||
|
|
||||||
// Forwarded image attachments — blurhash thumbnails (Android parity: ForwardedImagePreview).
|
// 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)
|
forwardedImagePreview(attachment: att, width: imageContentWidth, outgoing: outgoing)
|
||||||
.padding(.horizontal, 6)
|
.padding(.horizontal, 6)
|
||||||
.padding(.top, 4)
|
.padding(.top, 4)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Forwarded file attachments.
|
// Forwarded file attachments.
|
||||||
ForEach(Array(fileAttachments.enumerated()), id: \.element.id) { _, att in
|
ForEach(fileAttachments, id: \.id) { att in
|
||||||
forwardedFilePreview(attachment: att, outgoing: outgoing)
|
forwardedFilePreview(attachment: att, outgoing: outgoing)
|
||||||
.padding(.horizontal, 6)
|
.padding(.horizontal, 6)
|
||||||
.padding(.top, 4)
|
.padding(.top, 4)
|
||||||
@@ -1221,7 +1220,9 @@ private extension ChatDetailView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Non-image attachments (file, avatar) — padded
|
// Non-image attachments (file, avatar) — padded
|
||||||
|
// PERF: Group ensures 1 view per element → ForEach fast path.
|
||||||
ForEach(otherAttachments, id: \.id) { attachment in
|
ForEach(otherAttachments, id: \.id) { attachment in
|
||||||
|
Group {
|
||||||
switch attachment.type {
|
switch attachment.type {
|
||||||
case .file:
|
case .file:
|
||||||
MessageFileView(
|
MessageFileView(
|
||||||
@@ -1243,6 +1244,7 @@ private extension ChatDetailView {
|
|||||||
EmptyView()
|
EmptyView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Caption text below image
|
// Caption text below image
|
||||||
if hasCaption {
|
if hasCaption {
|
||||||
@@ -1384,7 +1386,11 @@ private extension ChatDetailView {
|
|||||||
/// Desktop parity: `TextParser.tsx` pattern `/:emoji_([a-zA-Z0-9_-]+):/`
|
/// Desktop parity: `TextParser.tsx` pattern `/:emoji_([a-zA-Z0-9_-]+):/`
|
||||||
/// Android parity: `unifiedToEmoji()` in `AppleEmojiPicker.kt`
|
/// Android parity: `unifiedToEmoji()` in `AppleEmojiPicker.kt`
|
||||||
private func parsedMarkdown(_ text: String) -> AttributedString {
|
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.
|
// Cross-platform: replace :emoji_CODE: shortcodes with native Unicode emoji.
|
||||||
let withEmoji = EmojiParser.replaceShortcodes(in: text)
|
let withEmoji = EmojiParser.replaceShortcodes(in: text)
|
||||||
@@ -1802,6 +1808,7 @@ private extension ChatDetailView {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
ForEach(otherAttachments, id: \.id) { attachment in
|
ForEach(otherAttachments, id: \.id) { attachment in
|
||||||
|
Group {
|
||||||
switch attachment.type {
|
switch attachment.type {
|
||||||
case .file:
|
case .file:
|
||||||
MessageFileView(attachment: attachment, message: message, outgoing: outgoing).padding(.horizontal, 4).padding(.top, 4)
|
MessageFileView(attachment: attachment, message: message, outgoing: outgoing).padding(.horizontal, 4).padding(.top, 4)
|
||||||
@@ -1810,6 +1817,7 @@ private extension ChatDetailView {
|
|||||||
default: EmptyView()
|
default: EmptyView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if hasCaption {
|
if hasCaption {
|
||||||
Text(parsedMarkdown(message.text))
|
Text(parsedMarkdown(message.text))
|
||||||
.font(.system(size: 17, weight: .regular))
|
.font(.system(size: 17, weight: .regular))
|
||||||
@@ -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) {
|
func openImageViewer(attachmentId: String) {
|
||||||
var allImageIds: [String] = []
|
var allImages: [ViewableImageInfo] = []
|
||||||
for message in messages {
|
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 {
|
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
|
let index = allImages.firstIndex(where: { $0.attachmentId == attachmentId }) ?? 0
|
||||||
imageViewerState = ImageViewerState(attachmentIds: allImageIds, initialIndex: index)
|
let state = ImageViewerState(images: allImages, initialIndex: index)
|
||||||
|
ImageViewerPresenter.shared.present(state: state)
|
||||||
}
|
}
|
||||||
|
|
||||||
func retryMessage(_ message: ChatMessage) {
|
func retryMessage(_ message: ChatMessage) {
|
||||||
|
|||||||
@@ -27,10 +27,13 @@ final class ChatDetailViewModel: ObservableObject {
|
|||||||
// Subscribe to messagesByDialog changes, filtered to our dialog only.
|
// Subscribe to messagesByDialog changes, filtered to our dialog only.
|
||||||
// Broken into steps to help the Swift type-checker.
|
// Broken into steps to help the Swift type-checker.
|
||||||
let key = dialogKey
|
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
|
let messagesPublisher = repo.$messagesByDialog
|
||||||
.map { (dict: [String: [ChatMessage]]) -> [ChatMessage] in
|
.map { (dict: [String: [ChatMessage]]) -> [ChatMessage] in
|
||||||
dict[key] ?? []
|
dict[key] ?? []
|
||||||
}
|
}
|
||||||
|
.debounce(for: .milliseconds(50), scheduler: DispatchQueue.main)
|
||||||
.removeDuplicates { (lhs: [ChatMessage], rhs: [ChatMessage]) -> Bool in
|
.removeDuplicates { (lhs: [ChatMessage], rhs: [ChatMessage]) -> Bool in
|
||||||
guard lhs.count == rhs.count else { return false }
|
guard lhs.count == rhs.count else { return false }
|
||||||
for i in lhs.indices {
|
for i in lhs.indices {
|
||||||
@@ -40,10 +43,10 @@ final class ChatDetailViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
.receive(on: DispatchQueue.main)
|
|
||||||
|
|
||||||
messagesPublisher
|
messagesPublisher
|
||||||
.sink { [weak self] newMessages in
|
.sink { [weak self] newMessages in
|
||||||
|
PerformanceLogger.shared.track("chatDetail.messagesEmit")
|
||||||
self?.messages = newMessages
|
self?.messages = newMessages
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|||||||
@@ -4,17 +4,76 @@ import Photos
|
|||||||
|
|
||||||
// MARK: - Data Types
|
// 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.
|
/// State for the image gallery viewer.
|
||||||
struct ImageViewerState: Equatable {
|
struct ImageViewerState: Equatable {
|
||||||
let attachmentIds: [String]
|
let images: [ViewableImageInfo]
|
||||||
let initialIndex: Int
|
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
|
// MARK: - ImageGalleryViewer
|
||||||
|
|
||||||
/// Telegram-style multi-photo gallery viewer with horizontal paging.
|
/// Telegram-style multi-photo gallery viewer with horizontal paging.
|
||||||
/// Android parity: `ImageViewerScreen.kt` — HorizontalPager, zoom-to-point,
|
/// Android parity: `ImageViewerScreen.kt` — top bar with sender/date,
|
||||||
/// velocity dismiss, page counter, share/save.
|
/// bottom caption bar, edge-tap navigation, velocity dismiss, share/save.
|
||||||
struct ImageGalleryViewer: View {
|
struct ImageGalleryViewer: View {
|
||||||
|
|
||||||
let state: ImageViewerState
|
let state: ImageViewerState
|
||||||
@@ -23,6 +82,16 @@ struct ImageGalleryViewer: View {
|
|||||||
@State private var currentPage: Int
|
@State private var currentPage: Int
|
||||||
@State private var showControls = true
|
@State private var showControls = true
|
||||||
@State private var currentZoomScale: CGFloat = 1.0
|
@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) {
|
init(state: ImageViewerState, onDismiss: @escaping () -> Void) {
|
||||||
self.state = state
|
self.state = state
|
||||||
@@ -30,76 +99,135 @@ struct ImageGalleryViewer: View {
|
|||||||
self._currentPage = State(initialValue: state.initialIndex)
|
self._currentPage = State(initialValue: state.initialIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var currentInfo: ViewableImageInfo? {
|
||||||
|
state.images.indices.contains(currentPage) ? state.images[currentPage] : nil
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color.black.ignoresSafeArea()
|
// Background — fades during drag-to-dismiss and entry/exit
|
||||||
|
Color.black
|
||||||
|
.opacity(backgroundOpacity * presentationAlpha)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
// Pager
|
// Pager
|
||||||
TabView(selection: $currentPage) {
|
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(
|
ZoomableImagePage(
|
||||||
attachmentId: attachmentId,
|
attachmentId: info.attachmentId,
|
||||||
onDismiss: onDismiss,
|
onDismiss: { smoothDismiss() },
|
||||||
|
onDismissProgress: { progress in
|
||||||
|
backgroundOpacity = 1.0 - Double(progress) * 0.7
|
||||||
|
},
|
||||||
|
onDismissCancel: {
|
||||||
|
withAnimation(.easeOut(duration: 0.25)) {
|
||||||
|
backgroundOpacity = 1.0
|
||||||
|
}
|
||||||
|
},
|
||||||
showControls: $showControls,
|
showControls: $showControls,
|
||||||
currentScale: $currentZoomScale
|
currentScale: $currentZoomScale,
|
||||||
|
onEdgeTap: { direction in
|
||||||
|
navigateEdgeTap(direction: direction)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
.tag(index)
|
.tag(index)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||||
.disabled(currentZoomScale > 1.05)
|
.disabled(currentZoomScale > 1.05 || isDismissing)
|
||||||
|
.opacity(presentationAlpha)
|
||||||
|
|
||||||
// Controls overlay
|
// Controls overlay
|
||||||
if showControls {
|
|
||||||
controlsOverlay
|
controlsOverlay
|
||||||
.transition(.opacity)
|
.opacity(presentationAlpha)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.statusBarHidden(true)
|
.statusBarHidden(true)
|
||||||
.animation(.easeInOut(duration: 0.2), value: showControls)
|
|
||||||
.onChange(of: currentPage) { _, newPage in
|
|
||||||
prefetchAdjacentImages(around: newPage)
|
|
||||||
}
|
|
||||||
.onAppear {
|
.onAppear {
|
||||||
prefetchAdjacentImages(around: state.initialIndex)
|
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
|
// MARK: - Controls Overlay
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
private var controlsOverlay: some View {
|
private var controlsOverlay: some View {
|
||||||
VStack {
|
if showControls && !isDismissing {
|
||||||
// Top bar: close + counter — inside safe area to avoid notch/Dynamic Island overlap
|
VStack(spacing: 0) {
|
||||||
HStack {
|
topBar
|
||||||
Button { onDismiss() } label: {
|
Spacer()
|
||||||
Image(systemName: "xmark")
|
bottomBar
|
||||||
.font(.system(size: 17, weight: .semibold))
|
}
|
||||||
.foregroundStyle(.white)
|
.transition(.opacity)
|
||||||
.frame(width: 36, height: 36)
|
.animation(.easeInOut(duration: 0.2), value: showControls)
|
||||||
.background(Color.white.opacity(0.2))
|
}
|
||||||
.clipShape(Circle())
|
}
|
||||||
|
|
||||||
|
// 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(.leading, 16)
|
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
if state.attachmentIds.count > 1 {
|
// Page counter (if multiple images)
|
||||||
Text("\(currentPage + 1) / \(state.attachmentIds.count)")
|
if state.images.count > 1 {
|
||||||
|
Text("\(currentPage + 1) / \(state.images.count)")
|
||||||
.font(.system(size: 15, weight: .medium))
|
.font(.system(size: 15, weight: .medium))
|
||||||
.foregroundStyle(.white.opacity(0.8))
|
.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))
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
// MARK: - Bottom Bar (Caption + Share/Save)
|
||||||
|
|
||||||
// Invisible spacer to balance the close button
|
private var bottomBar: some View {
|
||||||
Color.clear.frame(width: 36, height: 36)
|
VStack(spacing: 0) {
|
||||||
.padding(.trailing, 16)
|
// 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))
|
||||||
}
|
}
|
||||||
.padding(.top, 54)
|
|
||||||
|
|
||||||
Spacer()
|
// Action buttons
|
||||||
|
|
||||||
// Bottom bar: share + save
|
|
||||||
HStack(spacing: 32) {
|
HStack(spacing: 32) {
|
||||||
Button { shareCurrentImage() } label: {
|
Button { shareCurrentImage() } label: {
|
||||||
Image(systemName: "square.and.arrow.up")
|
Image(systemName: "square.and.arrow.up")
|
||||||
@@ -118,15 +246,41 @@ struct ImageGalleryViewer: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 24)
|
.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
|
// MARK: - Actions
|
||||||
|
|
||||||
private func shareCurrentImage() {
|
private func shareCurrentImage() {
|
||||||
guard currentPage < state.attachmentIds.count,
|
guard let info = currentInfo,
|
||||||
let image = AttachmentCache.shared.loadImage(forAttachmentId: state.attachmentIds[currentPage])
|
let image = AttachmentCache.shared.loadImage(forAttachmentId: info.attachmentId)
|
||||||
else { return }
|
else { return }
|
||||||
|
|
||||||
let activityVC = UIActivityViewController(activityItems: [image], applicationActivities: nil)
|
let activityVC = UIActivityViewController(activityItems: [image], applicationActivities: nil)
|
||||||
@@ -146,8 +300,8 @@ struct ImageGalleryViewer: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func saveCurrentImage() {
|
private func saveCurrentImage() {
|
||||||
guard currentPage < state.attachmentIds.count,
|
guard let info = currentInfo,
|
||||||
let image = AttachmentCache.shared.loadImage(forAttachmentId: state.attachmentIds[currentPage])
|
let image = AttachmentCache.shared.loadImage(forAttachmentId: info.attachmentId)
|
||||||
else { return }
|
else { return }
|
||||||
|
|
||||||
PHPhotoLibrary.requestAuthorization(for: .addOnly) { status in
|
PHPhotoLibrary.requestAuthorization(for: .addOnly) { status in
|
||||||
@@ -159,11 +313,12 @@ struct ImageGalleryViewer: View {
|
|||||||
// MARK: - Prefetch
|
// MARK: - Prefetch
|
||||||
|
|
||||||
private func prefetchAdjacentImages(around index: Int) {
|
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
|
let i = index + offset
|
||||||
guard i >= 0, i < state.attachmentIds.count else { continue }
|
guard i >= 0, i < state.images.count else { continue }
|
||||||
// Touch cache to warm it (loads from disk if needed)
|
_ = AttachmentCache.shared.loadImage(forAttachmentId: state.images[i].attachmentId)
|
||||||
_ = AttachmentCache.shared.loadImage(forAttachmentId: state.attachmentIds[i])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,11 +152,15 @@ struct MessageImageView: View {
|
|||||||
/// Calculates display size respecting min/max constraints and aspect ratio (standalone mode).
|
/// Calculates display size respecting min/max constraints and aspect ratio (standalone mode).
|
||||||
private func constrainedSize(for img: UIImage) -> CGSize {
|
private func constrainedSize(for img: UIImage) -> CGSize {
|
||||||
let constrainedWidth = min(maxImageWidth, maxWidth)
|
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 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)
|
let finalWidth = min(constrainedWidth, displayHeight * aspectRatio)
|
||||||
return CGSize(width: finalWidth, height: displayHeight)
|
return CGSize(width: max(finalWidth, 1), height: max(displayHeight, 1))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Placeholder
|
// MARK: - Placeholder
|
||||||
@@ -214,6 +218,7 @@ struct MessageImageView: View {
|
|||||||
// MARK: - Download
|
// MARK: - Download
|
||||||
|
|
||||||
private func loadFromCache() {
|
private func loadFromCache() {
|
||||||
|
PerformanceLogger.shared.track("image.cacheLoad")
|
||||||
if let cached = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) {
|
if let cached = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) {
|
||||||
image = cached
|
image = cached
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,71 +3,40 @@ import UIKit
|
|||||||
|
|
||||||
// MARK: - ZoomableImagePage
|
// MARK: - ZoomableImagePage
|
||||||
|
|
||||||
/// Single page in the image gallery viewer with centroid-based zoom.
|
/// Single page in the image gallery viewer with UIKit-based gesture handling.
|
||||||
/// Android parity: `ZoomableImage` in `ImageViewerScreen.kt` — pinch zoom to centroid,
|
/// Android parity: `ZoomableImage` in `ImageViewerScreen.kt` — centroid-based pinch zoom,
|
||||||
/// double-tap to tap point, velocity-based dismiss, touch slop.
|
/// double-tap to tap point, velocity-based dismiss, axis locking, edge tap navigation.
|
||||||
struct ZoomableImagePage: View {
|
struct ZoomableImagePage: View {
|
||||||
|
|
||||||
let attachmentId: String
|
let attachmentId: String
|
||||||
let onDismiss: () -> Void
|
let onDismiss: () -> Void
|
||||||
|
let onDismissProgress: (CGFloat) -> Void
|
||||||
|
let onDismissCancel: () -> Void
|
||||||
@Binding var showControls: Bool
|
@Binding var showControls: Bool
|
||||||
@Binding var currentScale: CGFloat
|
@Binding var currentScale: CGFloat
|
||||||
|
let onEdgeTap: ((Int) -> Void)?
|
||||||
|
|
||||||
@State private var image: UIImage?
|
@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 {
|
var body: some View {
|
||||||
GeometryReader { geometry in
|
Group {
|
||||||
ZStack {
|
|
||||||
// Background fade during dismiss
|
|
||||||
Color.black
|
|
||||||
.opacity(backgroundOpacity)
|
|
||||||
.ignoresSafeArea()
|
|
||||||
|
|
||||||
if let image {
|
if let image {
|
||||||
imageContent(image, in: geometry)
|
ZoomableImageUIViewRepresentable(
|
||||||
|
image: image,
|
||||||
|
onDismiss: onDismiss,
|
||||||
|
onDismissProgress: onDismissProgress,
|
||||||
|
onDismissCancel: onDismissCancel,
|
||||||
|
onToggleControls: { showControls.toggle() },
|
||||||
|
onScaleChanged: { scale in currentScale = scale },
|
||||||
|
onEdgeTap: onEdgeTap
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
placeholder
|
placeholder
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
.task {
|
.task {
|
||||||
image = AttachmentCache.shared.loadImage(forAttachmentId: attachmentId)
|
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
|
// MARK: - Placeholder
|
||||||
@@ -81,118 +50,393 @@ struct ZoomableImagePage: View {
|
|||||||
.foregroundStyle(.white.opacity(0.5))
|
.foregroundStyle(.white.opacity(0.5))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Background Opacity
|
// MARK: - UIViewRepresentable
|
||||||
|
|
||||||
private var backgroundOpacity: Double {
|
/// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIView(_ view: ImageGestureContainerView, context: Context) {
|
||||||
|
view.onDismiss = onDismiss
|
||||||
|
view.onDismissProgress = onDismissProgress
|
||||||
|
view.onDismissCancel = onDismissCancel
|
||||||
|
view.onToggleControls = onToggleControls
|
||||||
|
view.onScaleChanged = onScaleChanged
|
||||||
|
view.onEdgeTap = onEdgeTap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if isDismissGesture {
|
||||||
|
dismissOffset = translation.y
|
||||||
let progress = min(abs(dismissOffset) / 300, 1.0)
|
let progress = min(abs(dismissOffset) / 300, 1.0)
|
||||||
return 1.0 - progress * 0.6
|
onDismissProgress?(progress)
|
||||||
}
|
applyTransform()
|
||||||
|
|
||||||
// MARK: - Double Tap (zoom to tap point)
|
|
||||||
|
|
||||||
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: - Pinch Gesture (zoom to centroid)
|
case .ended, .cancelled:
|
||||||
|
if currentScale > 1.05 {
|
||||||
private func pinchGesture(in size: CGSize) -> some Gesture {
|
clampOffset(animated: true)
|
||||||
MagnificationGesture()
|
} else if isDismissGesture {
|
||||||
.onChanged { value in
|
let velocityY = abs(velocity.y)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
)
|
|
||||||
} 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.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)
|
|
||||||
|
|
||||||
if abs(dismissOffset) > dismissDistanceThreshold || velocityY > dismissVelocityThreshold {
|
if abs(dismissOffset) > dismissDistanceThreshold || velocityY > dismissVelocityThreshold {
|
||||||
onDismiss()
|
// Dismiss with fade-out (Android: smoothDismiss 200ms fade)
|
||||||
|
onDismiss?()
|
||||||
} else {
|
} else {
|
||||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) {
|
// Snap back
|
||||||
dismissOffset = 0
|
dismissOffset = 0
|
||||||
|
onDismissCancel?()
|
||||||
|
applyTransform(animated: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
dismissStartTime = nil
|
isDismissGesture = false
|
||||||
|
gestureAxisLocked = false
|
||||||
|
|
||||||
|
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
|
// MARK: - Offset Clamping
|
||||||
|
|
||||||
private func clampOffset(in size: CGSize) {
|
private func clampOffset(animated: Bool) {
|
||||||
guard scale > 1.0 else {
|
guard currentScale > 1.0 else {
|
||||||
offset = .zero
|
currentOffset = .zero
|
||||||
|
applyTransform(animated: animated)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let maxOffsetX = size.width * (scale - 1) / 2
|
let clamped = clampedOffset()
|
||||||
let maxOffsetY = size.height * (scale - 1) / 2
|
if currentOffset != clamped {
|
||||||
offset = CGSize(
|
currentOffset = clamped
|
||||||
width: min(max(offset.width, -maxOffsetX), maxOffsetX),
|
applyTransform(animated: animated)
|
||||||
height: min(max(offset.height, -maxOffsetY), maxOffsetY)
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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> = []
|
@State private var typingDialogs: Set<String> = []
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
let _ = PerformanceLogger.shared.track("chatList.bodyEval")
|
||||||
// Use pre-partitioned arrays from ViewModel (single-pass O(n) instead of 3× filter).
|
// Use pre-partitioned arrays from ViewModel (single-pass O(n) instead of 3× filter).
|
||||||
let pinned = viewModel.allModePinned
|
let pinned = viewModel.allModePinned
|
||||||
let unpinned = viewModel.allModeUnpinned
|
let unpinned = viewModel.allModeUnpinned
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ struct ChatRowView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
let _ = PerformanceLogger.shared.track("chatRow.bodyEval")
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
avatarSection
|
avatarSection
|
||||||
.padding(.trailing, 10)
|
.padding(.trailing, 10)
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ struct MainTabView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
let _ = PerformanceLogger.shared.track("mainTab.bodyEval")
|
||||||
ZStack {
|
ZStack {
|
||||||
Group {
|
Group {
|
||||||
if #available(iOS 26.0, *) {
|
if #available(iOS 26.0, *) {
|
||||||
@@ -57,6 +58,11 @@ struct MainTabView: View {
|
|||||||
// Full-screen device verification overlay (observation-isolated).
|
// Full-screen device verification overlay (observation-isolated).
|
||||||
// Covers nav bar, search bar, and tab bar — desktop parity.
|
// Covers nav bar, search bar, and tab bar — desktop parity.
|
||||||
DeviceConfirmOverlay()
|
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)
|
Label(RosettaTab.chats.label, systemImage: RosettaTab.chats.icon)
|
||||||
}
|
}
|
||||||
.tag(RosettaTab.chats)
|
.tag(RosettaTab.chats)
|
||||||
.badge(chatUnreadCount)
|
.badge(cachedUnreadCount)
|
||||||
|
|
||||||
SettingsView(onLogout: onLogout, onAddAccount: handleAddAccount, isEditingProfile: $isSettingsEditPresented, isDetailPresented: $isSettingsDetailPresented)
|
SettingsView(onLogout: onLogout, onAddAccount: handleAddAccount, isEditingProfile: $isSettingsEditPresented, isDetailPresented: $isSettingsDetailPresented)
|
||||||
.tabItem {
|
.tabItem {
|
||||||
@@ -195,20 +201,39 @@ struct MainTabView: View {
|
|||||||
return [TabBadge(tab: .chats, text: chatUnreadBadge)]
|
return [TabBadge(tab: .chats, text: chatUnreadBadge)]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Int badge for iOS 26+ TabView — `.badge(0)` shows nothing,
|
/// PERF: cached unread count — updated by observation-isolated child view.
|
||||||
/// and being non-conditional preserves ChatListView's structural identity.
|
/// Reading DialogRepository.shared.sortedDialogs directly in MainTabView.body
|
||||||
private var chatUnreadCount: Int {
|
/// creates observation on the entire dialogs dictionary, causing full body
|
||||||
DialogRepository.shared.sortedDialogs
|
/// re-evaluation on every dialog mutation (online, typing, delivery).
|
||||||
.filter { !$0.isMuted }
|
@State private var cachedUnreadCount: Int = 0
|
||||||
.reduce(0) { $0 + $1.unreadCount }
|
|
||||||
}
|
|
||||||
|
|
||||||
private var chatUnreadBadge: String? {
|
private var chatUnreadBadge: String? {
|
||||||
let unread = chatUnreadCount
|
if cachedUnreadCount <= 0 {
|
||||||
if unread <= 0 {
|
|
||||||
return nil
|
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
|
themeCard
|
||||||
safetyCard
|
safetyCard
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
debugCard
|
||||||
|
#endif
|
||||||
|
|
||||||
rosettaPowerFooter
|
rosettaPowerFooter
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.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
|
// MARK: - Helpers
|
||||||
|
|
||||||
/// Figma row: 52pt height, 30×30 rounded icon (r7), 23pt symbol, Medium 17pt title.
|
/// Figma row: 52pt height, 30×30 rounded icon (r7), 23pt symbol, Medium 17pt title.
|
||||||
|
|||||||
Reference in New Issue
Block a user