Индикация прочтения в чат-листе + баблах + Telegram-exact галочка
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user