From 459ac4e4da2718b8036020e8df146b36eecb00f5 Mon Sep 17 00:00:00 2001 From: senseiGai Date: Thu, 16 Apr 2026 18:02:16 +0500 Subject: [PATCH] =?UTF-8?q?=D0=98=D0=BD=D0=B4=D0=B8=D0=BA=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=BF=D1=80=D0=BE=D1=87=D1=82=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=B2=20=D1=87=D0=B0=D1=82-=D0=BB=D0=B8=D1=81=D1=82?= =?UTF-8?q?=D0=B5=20+=20=D0=B1=D0=B0=D0=B1=D0=BB=D0=B0=D1=85=20+=20Telegra?= =?UTF-8?q?m-exact=20=D0=B3=D0=B0=D0=BB=D0=BE=D1=87=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/Repositories/DialogRepository.swift | 94 ++++++++++++++++++- .../Components/DeliveryCheckmark.swift | 60 ++++++++---- .../Chats/ChatList/UIKit/ChatListCell.swift | 7 ++ .../UIKit/ChatListCollectionController.swift | 11 ++- .../ChatList/UIKit/ChatListUIKitView.swift | 52 +++++++++- 5 files changed, 202 insertions(+), 22 deletions(-) diff --git a/Rosetta/Core/Data/Repositories/DialogRepository.swift b/Rosetta/Core/Data/Repositories/DialogRepository.swift index 267075a..32abf68 100644 --- a/Rosetta/Core/Data/Repositories/DialogRepository.swift +++ b/Rosetta/Core/Data/Repositories/DialogRepository.swift @@ -399,16 +399,104 @@ final class DialogRepository { } /// Android parity: recalculate dialogs that changed during sync. - /// PERF: Only reconciles dirty keys (tracked via markDirtyForReconcile). - /// Falls back to full scan if no dirty keys tracked (legacy callers). + /// PERF: Batches all mutations into a single `dialogs` assignment + /// to trigger `didSet` (and observation) exactly once, not N times. func reconcileAllDialogs() { let keysToReconcile = dirtyReconcileKeys.isEmpty ? Set(dialogs.keys) : dirtyReconcileKeys dirtyReconcileKeys.removeAll() + + guard !keysToReconcile.isEmpty else { return } + + var batch = dialogs + var sortOrderChanged = false + for opponentKey in keysToReconcile { - updateDialogFromMessages(opponentKey: opponentKey) + let account = currentAccount + let isSavedMessages = opponentKey == account + let isSystem = SystemAccounts.isSystemAccount(opponentKey) + let isGroupDialog = GroupRepository.shared.isGroupDialog(opponentKey) + + guard let lastMsg = MessageRepository.shared.lastDecryptedMessage( + account: account, opponentKey: opponentKey + ) else { + if batch[opponentKey] != nil { + batch.removeValue(forKey: opponentKey) + sortOrderChanged = true + try? db.writeSync { db in + try db.execute( + sql: "DELETE FROM dialogs WHERE account = ? AND opponent_key = ?", + arguments: [account, opponentKey] + ) + } + } + continue + } + + let unread: Int = isSavedMessages ? 0 + : MessageRepository.shared.countUnread(account: account, opponentKey: opponentKey) + let hasSent = MessageRepository.shared.hasSentMessages(account: account, opponentKey: opponentKey) + + let textIsEmpty = lastMsg.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || Self.isGarbageText(lastMsg.text) + let lastMessageText: String + if textIsEmpty, let firstAtt = lastMsg.attachments.first { + switch firstAtt.type { + case .image: lastMessageText = "Photo" + case .file: lastMessageText = "File" + case .avatar: lastMessageText = "Avatar" + case .messages: lastMessageText = "Forwarded message" + case .call: lastMessageText = "Call" + case .voice: lastMessageText = "Voice message" + @unknown default: lastMessageText = "Attachment" + } + } else if textIsEmpty { + lastMessageText = "" + } else if lastMsg.text.hasPrefix("$a=") { + let action = String(lastMsg.text.dropFirst(3)) + switch action { + case "Group created": lastMessageText = "Group created" + case "Group joined": lastMessageText = "You joined the group" + default: lastMessageText = action + } + } else { + lastMessageText = lastMsg.text + } + + let lastFromMe = lastMsg.fromPublicKey == account + var dialog = batch[opponentKey] ?? Dialog( + id: opponentKey, account: account, opponentKey: opponentKey, + opponentTitle: "", opponentUsername: "", + lastMessage: "", lastMessageTimestamp: 0, unreadCount: 0, + isOnline: false, lastSeen: 0, verified: 0, + iHaveSent: false, isPinned: false, isMuted: false, + lastMessageFromMe: false, lastMessageDelivered: .waiting, + lastMessageRead: false + ) + + let oldTimestamp = batch[opponentKey]?.lastMessageTimestamp + dialog.lastMessage = lastMessageText + dialog.lastMessageTimestamp = lastMsg.timestamp + dialog.unreadCount = unread + dialog.iHaveSent = hasSent || isSystem || isGroupDialog + dialog.lastMessageFromMe = lastFromMe + dialog.lastMessageDelivered = lastFromMe ? lastMsg.deliveryStatus : .delivered + dialog.lastMessageRead = lastFromMe ? lastMsg.isRead : false + dialog.lastMessageSenderKey = lastMsg.fromPublicKey + + if batch[opponentKey] == nil || oldTimestamp != dialog.lastMessageTimestamp + || batch[opponentKey]?.isPinned != dialog.isPinned { + sortOrderChanged = true + } + batch[opponentKey] = dialog + persistDialog(dialog) } + + // Single assignment — triggers didSet exactly once + dialogs = batch + if sortOrderChanged { invalidateSortOrder() } + scheduleAppBadgeUpdate() } /// Legacy shims — both route to reconcileAllDialogs (no-op if dirty set empty). diff --git a/Rosetta/DesignSystem/Components/DeliveryCheckmark.swift b/Rosetta/DesignSystem/Components/DeliveryCheckmark.swift index acff86e..8c13e46 100644 --- a/Rosetta/DesignSystem/Components/DeliveryCheckmark.swift +++ b/Rosetta/DesignSystem/Components/DeliveryCheckmark.swift @@ -2,26 +2,54 @@ import SwiftUI // MARK: - Single Checkmark (Delivered) -/// Single checkmark shape — Telegram-exact geometry from ChatListStatusNode.swift. -/// Stroke-based V-path converted to filled outline via `strokedPath`. -/// Canonical coordinates: bottom-left (0, 4.5), inflection (3.5, 8.0), tip (11.0, 0). +/// Single checkmark shape — identical path to left checkmark in DoubleCheckmarkShape. +/// Filled bezier path with rounded caps built into the curves. +/// ViewBox: 0 0 16.4 12 (same coordinate space as double, cropped to single). struct SingleCheckmarkShape: Shape { func path(in rect: CGRect) -> Path { - let viewW: CGFloat = 11.0 - let viewH: CGFloat = 8.0 - let sx = rect.width / viewW - let sy = rect.height / viewH + let sx = rect.width / 16.4 + let sy = rect.height / 12.0 - var line = Path() - line.move(to: CGPoint(x: 0, y: 4.5 * sy)) - line.addLine(to: CGPoint(x: 3.5 * sx, y: 8.0 * sy)) - line.addLine(to: CGPoint(x: 11.0 * sx, y: 0)) + var path = Path() - return line.strokedPath(StrokeStyle( - lineWidth: min(sx, sy) * 1.35, - lineCap: .round, - lineJoin: .round - )) + path.move(to: CGPoint(x: 16.0745 * sx, y: 1.13501 * sy)) + path.addCurve( + to: CGPoint(x: 16.0378 * sx, y: 0.180235 * sy), + control1: CGPoint(x: 16.3354 * sx, y: 0.86165 * sy), + control2: CGPoint(x: 16.3191 * sx, y: 0.433785 * sy) + ) + path.addCurve( + to: CGPoint(x: 15.0553 * sx, y: 0.21589 * sy), + control1: CGPoint(x: 15.7565 * sx, y: -0.0733151 * sy), + control2: CGPoint(x: 15.3162 * sx, y: -0.0574685 * sy) + ) + path.addLine(to: CGPoint(x: 5.40975 * sx, y: 10.3103 * sy)) + path.addLine(to: CGPoint(x: 1.21886 * sx, y: 5.56025 * sy)) + path.addCurve( + to: CGPoint(x: 0.240444 * sx, y: 5.48894 * sy), + control1: CGPoint(x: 0.97018 * sx, y: 5.27897 * sy), + control2: CGPoint(x: 0.533969 * sx, y: 5.24727 * sy) + ) + path.addCurve( + to: CGPoint(x: 0.167063 * sx, y: 6.43975 * sy), + control1: CGPoint(x: -0.049005 * sx, y: 5.7306 * sy), + control2: CGPoint(x: -0.0816189 * sx, y: 6.15451 * sy) + ) + path.addLine(to: CGPoint(x: 4.86346 * sx, y: 11.7643 * sy)) + path.addCurve( + to: CGPoint(x: 5.37713 * sx, y: 11.998 * sy), + control1: CGPoint(x: 4.98984 * sx, y: 11.9109 * sy), + control2: CGPoint(x: 5.17737 * sx, y: 11.9941 * sy) + ) + path.addCurve( + to: CGPoint(x: 5.89895 * sx, y: 11.7841 * sy), + control1: CGPoint(x: 5.57281 * sx, y: 12.002 * sy), + control2: CGPoint(x: 5.76442 * sx, y: 11.9228 * sy) + ) + path.addLine(to: CGPoint(x: 16.0745 * sx, y: 1.13501 * sy)) + path.closeSubpath() + + return path } } diff --git a/Rosetta/Features/Chats/ChatList/UIKit/ChatListCell.swift b/Rosetta/Features/Chats/ChatList/UIKit/ChatListCell.swift index b16ebeb..70a4202 100644 --- a/Rosetta/Features/Chats/ChatList/UIKit/ChatListCell.swift +++ b/Rosetta/Features/Chats/ChatList/UIKit/ChatListCell.swift @@ -667,6 +667,9 @@ final class ChatListCell: UICollectionViewCell { let wasShowing = wasVisible wasVisible = shouldShow + // PERF: Cancel any in-flight animation to prevent stacking during rapid reconfigure + view.layer.removeAllAnimations() + if shouldShow && !wasShowing { // Appear: pop in with bounce view.isHidden = false @@ -895,6 +898,10 @@ final class ChatListCell: UICollectionViewCell { typingDotsView.stopAnimating() typingDotsView.isHidden = true typingLabel.isHidden = true + // PERF: Cancel in-flight badge animations to prevent blue flash during rapid reuse + badgeContainer.layer.removeAllAnimations() + mentionImageView.layer.removeAllAnimations() + statusImageView.layer.removeAllAnimations() // Badge animation state wasBadgeVisible = false wasMentionBadgeVisible = false diff --git a/Rosetta/Features/Chats/ChatList/UIKit/ChatListCollectionController.swift b/Rosetta/Features/Chats/ChatList/UIKit/ChatListCollectionController.swift index 1502441..1426895 100644 --- a/Rosetta/Features/Chats/ChatList/UIKit/ChatListCollectionController.swift +++ b/Rosetta/Features/Chats/ChatList/UIKit/ChatListCollectionController.swift @@ -46,6 +46,8 @@ final class ChatListCollectionController: UIViewController { private var searchHeaderExpansion: CGFloat = 1.0 private var hasInitializedTopOffset = false private var isPinnedFractionReportScheduled = false + /// PERF: Track last structural snapshot apply to suppress animations during bursts. + private var lastStructureChangeTime: CFAbsoluteTime = 0 // MARK: - UI @@ -403,7 +405,14 @@ final class ChatListCollectionController: UIViewController { snapshot.appendSections([.unpinned]) snapshot.appendItems(newUnpinnedIds, toSection: .unpinned) - dataSource.apply(snapshot, animatingDifferences: true) { [weak self] in + // PERF: Suppress animations during sync or rapid structural changes + // to prevent overlapping UIKit diffable-data-source animations (blue flash). + let now = CFAbsoluteTimeGetCurrent() + let timeSinceLastChange = now - lastStructureChangeTime + lastStructureChangeTime = now + let shouldAnimate = !isSyncing && timeSinceLastChange > 0.3 + + dataSource.apply(snapshot, animatingDifferences: shouldAnimate) { [weak self] in self?.reportPinnedHeaderFraction(force: true) } } else { diff --git a/Rosetta/Features/Chats/ChatList/UIKit/ChatListUIKitView.swift b/Rosetta/Features/Chats/ChatList/UIKit/ChatListUIKitView.swift index 6ce0c8a..4f32482 100644 --- a/Rosetta/Features/Chats/ChatList/UIKit/ChatListUIKitView.swift +++ b/Rosetta/Features/Chats/ChatList/UIKit/ChatListUIKitView.swift @@ -113,6 +113,11 @@ final class ChatListRootViewController: UIViewController, UINavigationController private var openChatObserver: NSObjectProtocol? private var didBecomeActiveObserver: NSObjectProtocol? + // PERF: Throttle render to prevent rapid-fire UI updates (blue flash bug). + private var pendingRenderWork: DispatchWorkItem? + private var lastRenderTime: CFAbsoluteTime = 0 + private let renderThrottleInterval: TimeInterval = 0.1 + private lazy var editButtonControl: ChatListToolbarEditButton = { let button = ChatListToolbarEditButton() button.addTarget(self, action: #selector(editTapped), for: .touchUpInside) @@ -467,12 +472,35 @@ final class ChatListRootViewController: UIViewController, UINavigationController } onChange: { [weak self] in Task { @MainActor [weak self] in guard let self else { return } - self.render() + self.scheduleRender() self.observeState() } } } + /// PERF: Coalesce rapid observation triggers into a single render. + /// First render in a burst fires immediately; subsequent within 100ms are batched. + private func scheduleRender() { + pendingRenderWork?.cancel() + + let now = CFAbsoluteTimeGetCurrent() + let elapsed = now - lastRenderTime + + if elapsed >= renderThrottleInterval { + lastRenderTime = now + render() + } else { + let delay = renderThrottleInterval - elapsed + let work = DispatchWorkItem { [weak self] in + guard let self else { return } + self.lastRenderTime = CFAbsoluteTimeGetCurrent() + self.render() + } + pendingRenderWork = work + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work) + } + } + private func render() { updateNavigationTitle() renderList() @@ -866,6 +894,8 @@ final class RequestChatsUIKitShellController: UIViewController { private let viewModel: ChatListViewModel private let requestsController = RequestChatsController() private var observationTask: Task? + private var pendingRenderWork: DispatchWorkItem? + private var lastRenderTime: CFAbsoluteTime = 0 // Custom header elements (direct subviews — glass needs this) private let headerBarHeight: CGFloat = 44 @@ -1037,12 +1067,30 @@ final class RequestChatsUIKitShellController: UIViewController { } onChange: { [weak self] in Task { @MainActor [weak self] in guard let self else { return } - self.render() + self.scheduleRender() self.observeState() } } } + private func scheduleRender() { + pendingRenderWork?.cancel() + let now = CFAbsoluteTimeGetCurrent() + if now - lastRenderTime >= 0.1 { + lastRenderTime = now + render() + } else { + let delay = 0.1 - (now - lastRenderTime) + let work = DispatchWorkItem { [weak self] in + guard let self else { return } + self.lastRenderTime = CFAbsoluteTimeGetCurrent() + self.render() + } + pendingRenderWork = work + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work) + } + } + private func render() { requestsController.updateDialogs( viewModel.requestsModeDialogs,