Индикация прочтения в чат-листе + баблах + Telegram-exact галочка
This commit is contained in:
@@ -399,16 +399,104 @@ final class DialogRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Android parity: recalculate dialogs that changed during sync.
|
/// Android parity: recalculate dialogs that changed during sync.
|
||||||
/// PERF: Only reconciles dirty keys (tracked via markDirtyForReconcile).
|
/// PERF: Batches all mutations into a single `dialogs` assignment
|
||||||
/// Falls back to full scan if no dirty keys tracked (legacy callers).
|
/// to trigger `didSet` (and observation) exactly once, not N times.
|
||||||
func reconcileAllDialogs() {
|
func reconcileAllDialogs() {
|
||||||
let keysToReconcile = dirtyReconcileKeys.isEmpty
|
let keysToReconcile = dirtyReconcileKeys.isEmpty
|
||||||
? Set(dialogs.keys)
|
? Set(dialogs.keys)
|
||||||
: dirtyReconcileKeys
|
: dirtyReconcileKeys
|
||||||
dirtyReconcileKeys.removeAll()
|
dirtyReconcileKeys.removeAll()
|
||||||
|
|
||||||
|
guard !keysToReconcile.isEmpty else { return }
|
||||||
|
|
||||||
|
var batch = dialogs
|
||||||
|
var sortOrderChanged = false
|
||||||
|
|
||||||
for opponentKey in keysToReconcile {
|
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).
|
/// Legacy shims — both route to reconcileAllDialogs (no-op if dirty set empty).
|
||||||
|
|||||||
@@ -2,26 +2,54 @@ import SwiftUI
|
|||||||
|
|
||||||
// MARK: - Single Checkmark (Delivered)
|
// MARK: - Single Checkmark (Delivered)
|
||||||
|
|
||||||
/// Single checkmark shape — Telegram-exact geometry from ChatListStatusNode.swift.
|
/// Single checkmark shape — identical path to left checkmark in DoubleCheckmarkShape.
|
||||||
/// Stroke-based V-path converted to filled outline via `strokedPath`.
|
/// Filled bezier path with rounded caps built into the curves.
|
||||||
/// Canonical coordinates: bottom-left (0, 4.5), inflection (3.5, 8.0), tip (11.0, 0).
|
/// ViewBox: 0 0 16.4 12 (same coordinate space as double, cropped to single).
|
||||||
struct SingleCheckmarkShape: Shape {
|
struct SingleCheckmarkShape: Shape {
|
||||||
func path(in rect: CGRect) -> Path {
|
func path(in rect: CGRect) -> Path {
|
||||||
let viewW: CGFloat = 11.0
|
let sx = rect.width / 16.4
|
||||||
let viewH: CGFloat = 8.0
|
let sy = rect.height / 12.0
|
||||||
let sx = rect.width / viewW
|
|
||||||
let sy = rect.height / viewH
|
|
||||||
|
|
||||||
var line = Path()
|
var path = 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))
|
|
||||||
|
|
||||||
return line.strokedPath(StrokeStyle(
|
path.move(to: CGPoint(x: 16.0745 * sx, y: 1.13501 * sy))
|
||||||
lineWidth: min(sx, sy) * 1.35,
|
path.addCurve(
|
||||||
lineCap: .round,
|
to: CGPoint(x: 16.0378 * sx, y: 0.180235 * sy),
|
||||||
lineJoin: .round
|
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
|
let wasShowing = wasVisible
|
||||||
wasVisible = shouldShow
|
wasVisible = shouldShow
|
||||||
|
|
||||||
|
// PERF: Cancel any in-flight animation to prevent stacking during rapid reconfigure
|
||||||
|
view.layer.removeAllAnimations()
|
||||||
|
|
||||||
if shouldShow && !wasShowing {
|
if shouldShow && !wasShowing {
|
||||||
// Appear: pop in with bounce
|
// Appear: pop in with bounce
|
||||||
view.isHidden = false
|
view.isHidden = false
|
||||||
@@ -895,6 +898,10 @@ final class ChatListCell: UICollectionViewCell {
|
|||||||
typingDotsView.stopAnimating()
|
typingDotsView.stopAnimating()
|
||||||
typingDotsView.isHidden = true
|
typingDotsView.isHidden = true
|
||||||
typingLabel.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
|
// Badge animation state
|
||||||
wasBadgeVisible = false
|
wasBadgeVisible = false
|
||||||
wasMentionBadgeVisible = false
|
wasMentionBadgeVisible = false
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ final class ChatListCollectionController: UIViewController {
|
|||||||
private var searchHeaderExpansion: CGFloat = 1.0
|
private var searchHeaderExpansion: CGFloat = 1.0
|
||||||
private var hasInitializedTopOffset = false
|
private var hasInitializedTopOffset = false
|
||||||
private var isPinnedFractionReportScheduled = false
|
private var isPinnedFractionReportScheduled = false
|
||||||
|
/// PERF: Track last structural snapshot apply to suppress animations during bursts.
|
||||||
|
private var lastStructureChangeTime: CFAbsoluteTime = 0
|
||||||
|
|
||||||
// MARK: - UI
|
// MARK: - UI
|
||||||
|
|
||||||
@@ -403,7 +405,14 @@ final class ChatListCollectionController: UIViewController {
|
|||||||
snapshot.appendSections([.unpinned])
|
snapshot.appendSections([.unpinned])
|
||||||
snapshot.appendItems(newUnpinnedIds, toSection: .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)
|
self?.reportPinnedHeaderFraction(force: true)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -113,6 +113,11 @@ final class ChatListRootViewController: UIViewController, UINavigationController
|
|||||||
private var openChatObserver: NSObjectProtocol?
|
private var openChatObserver: NSObjectProtocol?
|
||||||
private var didBecomeActiveObserver: 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 = {
|
private lazy var editButtonControl: ChatListToolbarEditButton = {
|
||||||
let button = ChatListToolbarEditButton()
|
let button = ChatListToolbarEditButton()
|
||||||
button.addTarget(self, action: #selector(editTapped), for: .touchUpInside)
|
button.addTarget(self, action: #selector(editTapped), for: .touchUpInside)
|
||||||
@@ -467,12 +472,35 @@ final class ChatListRootViewController: UIViewController, UINavigationController
|
|||||||
} onChange: { [weak self] in
|
} onChange: { [weak self] in
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
self.render()
|
self.scheduleRender()
|
||||||
self.observeState()
|
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() {
|
private func render() {
|
||||||
updateNavigationTitle()
|
updateNavigationTitle()
|
||||||
renderList()
|
renderList()
|
||||||
@@ -866,6 +894,8 @@ final class RequestChatsUIKitShellController: UIViewController {
|
|||||||
private let viewModel: ChatListViewModel
|
private let viewModel: ChatListViewModel
|
||||||
private let requestsController = RequestChatsController()
|
private let requestsController = RequestChatsController()
|
||||||
private var observationTask: Task<Void, Never>?
|
private var observationTask: Task<Void, Never>?
|
||||||
|
private var pendingRenderWork: DispatchWorkItem?
|
||||||
|
private var lastRenderTime: CFAbsoluteTime = 0
|
||||||
|
|
||||||
// Custom header elements (direct subviews — glass needs this)
|
// Custom header elements (direct subviews — glass needs this)
|
||||||
private let headerBarHeight: CGFloat = 44
|
private let headerBarHeight: CGFloat = 44
|
||||||
@@ -1037,12 +1067,30 @@ final class RequestChatsUIKitShellController: UIViewController {
|
|||||||
} onChange: { [weak self] in
|
} onChange: { [weak self] in
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
self.render()
|
self.scheduleRender()
|
||||||
self.observeState()
|
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() {
|
private func render() {
|
||||||
requestsController.updateDialogs(
|
requestsController.updateDialogs(
|
||||||
viewModel.requestsModeDialogs,
|
viewModel.requestsModeDialogs,
|
||||||
|
|||||||
Reference in New Issue
Block a user