Индикация прочтения в чат-листе + баблах + Telegram-exact галочка

This commit is contained in:
2026-04-16 18:02:16 +05:00
parent 4b1953a72e
commit 459ac4e4da
5 changed files with 202 additions and 22 deletions

View File

@@ -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).

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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<Void, Never>?
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,