Убран текст Message у пересланных голосовых + ширина bubble по duration

This commit is contained in:
2026-04-17 16:13:05 +05:00
parent 6db6a24969
commit 4d7dd826ad
9 changed files with 178 additions and 65 deletions

View File

@@ -949,7 +949,7 @@
C19929D9466573F31997B2C0 /* Release */, C19929D9466573F31997B2C0 /* Release */,
); );
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Debug;
}; };
853F295D2F4B50410092AD05 /* Build configuration list for PBXProject "Rosetta" */ = { 853F295D2F4B50410092AD05 /* Build configuration list for PBXProject "Rosetta" */ = {
isa = XCConfigurationList; isa = XCConfigurationList;
@@ -958,7 +958,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;
@@ -967,7 +967,7 @@
853F296F2F4B50420092AD05 /* Release */, 853F296F2F4B50420092AD05 /* Release */,
); );
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Debug;
}; };
A8D200712F9000010092AD05 /* Build configuration list for PBXNativeTarget "RosettaUITests" */ = { A8D200712F9000010092AD05 /* Build configuration list for PBXNativeTarget "RosettaUITests" */ = {
isa = XCConfigurationList; isa = XCConfigurationList;
@@ -976,7 +976,7 @@
A8D200622F9000010092AD05 /* Release */, A8D200622F9000010092AD05 /* 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;
@@ -985,7 +985,7 @@
0140D6320A9CF4B5E933E0B1 /* Debug */, 0140D6320A9CF4B5E933E0B1 /* Debug */,
); );
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Debug;
}; };
LA00000062F8D22220092AD05 /* Build configuration list for PBXNativeTarget "RosettaLiveActivityWidget" */ = { LA00000062F8D22220092AD05 /* Build configuration list for PBXNativeTarget "RosettaLiveActivityWidget" */ = {
isa = XCConfigurationList; isa = XCConfigurationList;
@@ -994,7 +994,7 @@
LA00000082F8D22220092AD05 /* Release */, LA00000082F8D22220092AD05 /* Release */,
); );
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Debug;
}; };
/* End XCConfigurationList section */ /* End XCConfigurationList section */

View File

@@ -66,7 +66,7 @@
</Testables> </Testables>
</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"

View File

@@ -1180,6 +1180,32 @@ extension MessageCellLayout {
extension MessageCellLayout { extension MessageCellLayout {
// Static DateFormatters avoid creating 3 instances per batchCalculate call.
// DateFormatter init + configuration ~0.5ms each × 3 × every call = significant in hot path.
// nonisolated(unsafe) is safe: LayoutEngine actor serializes all batchCalculate calls,
// and the sync path runs on main thread (single-threaded).
nonisolated(unsafe) private static let timestampFmt: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "HH:mm"
f.locale = .autoupdatingCurrent
f.timeZone = .autoupdatingCurrent
return f
}()
nonisolated(unsafe) private static let sameYearHeaderFmt: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "MMMM d"
f.locale = .autoupdatingCurrent
return f
}()
nonisolated(unsafe) private static let diffYearHeaderFmt: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "MMMM d, yyyy"
f.locale = .autoupdatingCurrent
return f
}()
/// Pre-calculate layouts for all messages on background queue. /// Pre-calculate layouts for all messages on background queue.
/// Returns both frame layouts AND cached CoreTextTextLayouts for cell rendering. /// Returns both frame layouts AND cached CoreTextTextLayouts for cell rendering.
/// Telegram equivalent: ListView calls asyncLayout() on background. /// Telegram equivalent: ListView calls asyncLayout() on background.
@@ -1198,19 +1224,9 @@ extension MessageCellLayout {
) -> (layouts: [String: MessageCellLayout], textLayouts: [String: CoreTextTextLayout]) { ) -> (layouts: [String: MessageCellLayout], textLayouts: [String: CoreTextTextLayout]) {
var result: [String: MessageCellLayout] = existingLayouts ?? [:] var result: [String: MessageCellLayout] = existingLayouts ?? [:]
var textResult: [String: CoreTextTextLayout] = existingTextLayouts ?? [:] var textResult: [String: CoreTextTextLayout] = existingTextLayouts ?? [:]
let timestampFormatter = DateFormatter()
timestampFormatter.dateFormat = "HH:mm"
timestampFormatter.locale = .autoupdatingCurrent
timestampFormatter.timeZone = .autoupdatingCurrent
let calendar = Calendar.current let calendar = Calendar.current
let now = Date() let now = Date()
let sameYearFormatter = DateFormatter()
sameYearFormatter.dateFormat = "MMMM d"
sameYearFormatter.locale = .autoupdatingCurrent
let diffYearFormatter = DateFormatter()
diffYearFormatter.dateFormat = "MMMM d, yyyy"
diffYearFormatter.locale = .autoupdatingCurrent
for (index, message) in messages.enumerated() { for (index, message) in messages.enumerated() {
// Incremental: skip messages not in dirty set (reuse existing cache) // Incremental: skip messages not in dirty set (reuse existing cache)
@@ -1219,7 +1235,7 @@ extension MessageCellLayout {
} }
let isOutgoing = message.fromPublicKey == currentPublicKey let isOutgoing = message.fromPublicKey == currentPublicKey
let messageDate = Date(timeIntervalSince1970: TimeInterval(message.timestamp) / 1000) let messageDate = Date(timeIntervalSince1970: TimeInterval(message.timestamp) / 1000)
let timestampText = timestampFormatter.string(from: messageDate) let timestampText = Self.timestampFmt.string(from: messageDate)
// Date header: show on first message of each calendar day // Date header: show on first message of each calendar day
let showsDateHeader: Bool let showsDateHeader: Bool
@@ -1231,8 +1247,8 @@ extension MessageCellLayout {
} }
let dateHeaderText = showsDateHeader let dateHeaderText = showsDateHeader
? Self.formatDateHeader(messageDate, now: now, calendar: calendar, ? Self.formatDateHeader(messageDate, now: now, calendar: calendar,
sameYearFormatter: sameYearFormatter, sameYearFormatter: Self.sameYearHeaderFmt,
diffYearFormatter: diffYearFormatter) diffYearFormatter: Self.diffYearHeaderFmt)
: "" : ""
// Filter garbage/encrypted text (UIKit path parity with SwiftUI MessageCellView) // Filter garbage/encrypted text (UIKit path parity with SwiftUI MessageCellView)
@@ -1298,6 +1314,7 @@ extension MessageCellLayout {
var forwardInnerImageCount = 0 var forwardInnerImageCount = 0
var forwardInnerFileCount = 0 var forwardInnerFileCount = 0
var forwardInnerVoiceCount = 0 var forwardInnerVoiceCount = 0
var forwardVoiceDuration: TimeInterval = 0
var forwardSenderName = "" var forwardSenderName = ""
var forwardChachaKeyPlain = "" var forwardChachaKeyPlain = ""
var forwardAttachments: [ReplyAttachmentData] = [] var forwardAttachments: [ReplyAttachmentData] = []
@@ -1314,6 +1331,18 @@ extension MessageCellLayout {
forwardInnerImageCount = first.attachments.filter { $0.type == 0 }.count forwardInnerImageCount = first.attachments.filter { $0.type == 0 }.count
forwardInnerFileCount = first.attachments.filter { $0.type == 2 }.count forwardInnerFileCount = first.attachments.filter { $0.type == 2 }.count
forwardInnerVoiceCount = first.attachments.filter { $0.type == 5 }.count forwardInnerVoiceCount = first.attachments.filter { $0.type == 5 }.count
// Suppress caption for voice-only forwards (Telegram parity)
if forwardInnerVoiceCount > 0 && forwardInnerImageCount == 0 && forwardInnerFileCount == 0 {
forwardCaption = nil
}
// Parse forward voice duration for bubble width scaling
if forwardInnerVoiceCount > 0, voiceDuration == 0,
let fwdVoicePreview = first.attachments.first(where: { $0.type == 5 })?.preview {
let parts = fwdVoicePreview.components(separatedBy: "::")
if parts.count >= 3, let dur = Int(parts[1]) { forwardVoiceDuration = TimeInterval(dur) }
else if parts.count >= 2, let dur = Int(parts[0]) { forwardVoiceDuration = TimeInterval(dur) }
else if let dur = Int(parts[0]) { forwardVoiceDuration = TimeInterval(dur) }
}
forwardChachaKeyPlain = first.chacha_key_plain forwardChachaKeyPlain = first.chacha_key_plain
forwardAttachments = first.attachments forwardAttachments = first.attachments
// Resolve forward sender name for dynamic bubble width // Resolve forward sender name for dynamic bubble width
@@ -1394,7 +1423,7 @@ extension MessageCellLayout {
avatarCount: avatars.count, avatarCount: avatars.count,
callCount: calls.count, callCount: calls.count,
voiceCount: voices.count, voiceCount: voices.count,
voiceDuration: voiceDuration, voiceDuration: voiceDuration > 0 ? voiceDuration : forwardVoiceDuration,
isForward: isForward, isForward: isForward,
forwardImageCount: forwardInnerImageCount, forwardImageCount: forwardInnerImageCount,
forwardFileCount: forwardInnerFileCount, forwardFileCount: forwardInnerFileCount,

View File

@@ -1,6 +1,7 @@
import UIKit import UIKit
import Combine import Combine
import SwiftUI import SwiftUI
import os
// MARK: - ChatDetailViewController // MARK: - ChatDetailViewController
@@ -9,6 +10,8 @@ import SwiftUI
/// Phase 1: shell + child VC + state + callbacks + Combine subscriptions. /// Phase 1: shell + child VC + state + callbacks + Combine subscriptions.
final class ChatDetailViewController: UIViewController { final class ChatDetailViewController: UIViewController {
private static let perfLog = Logger(subsystem: "com.rosetta.messenger", category: "ChatOpen")
// MARK: - Route & ViewModel // MARK: - Route & ViewModel
let route: ChatRoute let route: ChatRoute
@@ -104,6 +107,7 @@ final class ChatDetailViewController: UIViewController {
// MARK: - Lifecycle // MARK: - Lifecycle
override func viewDidLoad() { override func viewDidLoad() {
let vdlStart = CACurrentMediaTime()
super.viewDidLoad() super.viewDidLoad()
view.backgroundColor = UIColor { $0.userInterfaceStyle == .dark ? .black : .white } view.backgroundColor = UIColor { $0.userInterfaceStyle == .dark ? .black : .white }
@@ -119,6 +123,8 @@ final class ChatDetailViewController: UIViewController {
setupEdgeEffects() setupEdgeEffects()
wireCellActions() wireCellActions()
wireViewModelSubscriptions() wireViewModelSubscriptions()
let vdlMs = (CACurrentMediaTime() - vdlStart) * 1000
Self.perfLog.info("⚡ viewDidLoad \(String(format: "%.0f", vdlMs))ms | msgs=\(self.viewModel.messages.count)")
// Show forward preview bar if VC was opened with pending forward data // Show forward preview bar if VC was opened with pending forward data
if pendingForwardData != nil { if pendingForwardData != nil {
@@ -259,9 +265,14 @@ final class ChatDetailViewController: UIViewController {
// skeleton flash even when messages are cached. // skeleton flash even when messages are cached.
let initialMessages = viewModel.messages.filter { !$0.text.hasPrefix("$a=") } let initialMessages = viewModel.messages.filter { !$0.text.hasPrefix("$a=") }
if !initialMessages.isEmpty { if !initialMessages.isEmpty {
let seedStart = CACurrentMediaTime()
controller.updateEmptyState(isEmpty: false, info: makeEmptyChatInfo()) controller.updateEmptyState(isEmpty: false, info: makeEmptyChatInfo())
controller.update(messages: initialMessages) controller.update(messages: initialMessages)
lastMessageFingerprint = messageFingerprint(viewModel.messages) lastMessageFingerprint = messageFingerprint(viewModel.messages)
let seedMs = (CACurrentMediaTime() - seedStart) * 1000
Self.perfLog.info("⚡ seed \(initialMessages.count) msgs → update() \(String(format: "%.0f", seedMs))ms")
} else {
Self.perfLog.info("⚡ seed: no cached msgs → skeleton")
} }
// Fix date pill sticky offset for floating header // Fix date pill sticky offset for floating header

View File

@@ -1,5 +1,7 @@
import Foundation import Foundation
import Combine import Combine
import os
import QuartzCore
/// Per-dialog observation isolation for ChatDetailView. /// Per-dialog observation isolation for ChatDetailView.
/// ///
@@ -28,15 +30,25 @@ final class ChatDetailViewModel: ObservableObject {
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
private static let perfLog = Logger(subsystem: "com.rosetta.messenger", category: "ChatOpen")
init(dialogKey: String) { init(dialogKey: String) {
self.dialogKey = dialogKey self.dialogKey = dialogKey
let repo = MessageRepository.shared let repo = MessageRepository.shared
// Reset cache to latest messages prevents stale scrolled-up window from previous visit.
// reloadLatest loads maxCacheSize (200) msgs from DB sync (~60ms).
// Hidden behind push transition animation (350ms).
repo.reloadLatest(for: dialogKey)
// Seed with current values // Seed with current values
let vmStart = CACurrentMediaTime()
let initial = repo.messages(for: dialogKey) let initial = repo.messages(for: dialogKey)
messages = initial messages = initial
isTyping = repo.isTyping(dialogKey: dialogKey) isTyping = repo.isTyping(dialogKey: dialogKey)
let vmMs = (CACurrentMediaTime() - vmStart) * 1000
Self.perfLog.info("⚡ ViewModel.init: \(initial.count) cached msgs loaded in \(String(format: "%.1f", vmMs))ms")
// Android parity: if we already have messages, skip skeleton. // Android parity: if we already have messages, skip skeleton.
// Otherwise keep isLoading=true until first Combine emission or timeout. // Otherwise keep isLoading=true until first Combine emission or timeout.
isLoading = initial.isEmpty isLoading = initial.isEmpty

View File

@@ -136,6 +136,9 @@ final class MessageVoiceView: UIView {
width: max(0, waveW), width: max(0, waveW),
height: waveformHeight height: waveformHeight
) )
// Force redraw after layout frame change alone may not trigger display
// when the size stays the same between reuse cycles.
waveformView.setNeedsDisplay()
// Duration: at x=56, y=22 // Duration: at x=56, y=22
durationLabel.frame = CGRect( durationLabel.frame = CGRect(
@@ -164,8 +167,17 @@ final class MessageVoiceView: UIView {
self.totalDuration = duration self.totalDuration = duration
// Decode waveform from preview // Decode waveform from preview fallback to flat bars if empty
let samples = Self.decodeWaveform(from: preview) var samples = Self.decodeWaveform(from: preview)
#if DEBUG
if samples.isEmpty {
print("[VOICE_WAVEFORM] empty waveform — msgId=\(messageId.prefix(8))… preview='\(preview.prefix(80))'")
}
#endif
if samples.isEmpty {
// Telegram shows flat bars when waveform data is missing (better than empty space)
samples = [Float](repeating: 0.3, count: 30)
}
waveformView.setSamples(samples) waveformView.setSamples(samples)
waveformView.progress = 0 waveformView.progress = 0

View File

@@ -1872,15 +1872,25 @@ final class NativeMessageCell: UICollectionViewCell {
/// Parse voice preview: "tag::duration::waveform" or "duration::waveform" /// Parse voice preview: "tag::duration::waveform" or "duration::waveform"
static func parseVoicePreview(_ preview: String) -> (duration: TimeInterval, waveform: String) { static func parseVoicePreview(_ preview: String) -> (duration: TimeInterval, waveform: String) {
let parts = preview.components(separatedBy: "::") let parts = preview.components(separatedBy: "::")
// Format: "tag::duration::waveform" or "duration::waveform" // Format: "tag::duration::waveform" or "duration::waveform" or "::duration::waveform"
if parts.count >= 3, let dur = Int(parts[1]) { // Try 3+ parts: tag::duration::waveform (or leading empty ::duration::waveform)
return (TimeInterval(dur), parts[2]) if parts.count >= 3 {
} else if parts.count >= 2, let dur = Int(parts[0]) { // Check parts[1] first (tag::dur::wave), then parts[0] if that fails (edge cases)
return (TimeInterval(dur), parts[1]) if let dur = Int(parts[1]) {
} else if let dur = Int(parts[0]) { return (TimeInterval(dur), parts.dropFirst(2).joined(separator: "::"))
} else if let dur = Int(parts[0]) {
return (TimeInterval(dur), parts.dropFirst(1).joined(separator: "::"))
}
}
// 2 parts: duration::waveform
if parts.count >= 2, let dur = Int(parts[0]) {
return (TimeInterval(dur), parts.dropFirst(1).joined(separator: "::"))
}
// Single part: just duration (no waveform)
if let dur = Int(parts[0]) {
return (TimeInterval(dur), "") return (TimeInterval(dur), "")
} }
return (0, preview) return (0, "")
} }
private static func resolvePlayableVoiceURL( private static func resolvePlayableVoiceURL(

View File

@@ -1,4 +1,5 @@
import Combine import Combine
import os
import SwiftUI import SwiftUI
import UIKit import UIKit
@@ -23,6 +24,8 @@ struct ReplyDataCacheEntry {
@MainActor @MainActor
final class NativeMessageListController: UIViewController { final class NativeMessageListController: UIViewController {
private static let perfLog = Logger(subsystem: "com.rosetta.messenger", category: "ChatOpen")
private enum UIConstants { private enum UIConstants {
static let messageToComposerGap: CGFloat = 16 static let messageToComposerGap: CGFloat = 16
static let scrollButtonSize: CGFloat = 40 static let scrollButtonSize: CGFloat = 40
@@ -1498,10 +1501,9 @@ final class NativeMessageListController: UIViewController {
: [] : []
if isInteractive { if isInteractive {
for ip in collectionView.indexPathsForVisibleItems { for ip in collectionView.indexPathsForVisibleItems {
if let msgId = messageId(for: ip), guard let itemId = dataSource.itemIdentifier(for: ip),
let cell = collectionView.cellForItem(at: ip) { let cell = collectionView.cellForItem(at: ip) else { continue }
oldPositions[msgId] = cell.layer.position.y oldPositions[itemId] = cell.layer.position.y
}
} }
} }
@@ -1527,8 +1529,26 @@ final class NativeMessageListController: UIViewController {
// Interactive inserts (3 new messages) MUST be sync to avoid delayed // Interactive inserts (3 new messages) MUST be sync to avoid delayed
// reconfigureVisibleCells() from calculateLayoutsAsync killing animations. // reconfigureVisibleCells() from calculateLayoutsAsync killing animations.
if layoutCache.isEmpty { if layoutCache.isEmpty {
// First load: synchronous to avoid blank cells // First load: sync-calculate only the visible tail (newest messages
calculateLayouts() // shown at bottom of inverted list). 25 covers ~15 visible + scroll buffer.
// Remaining layouts computed async via LayoutEngine actor.
let syncCount = min(messages.count, 25)
if syncCount < messages.count {
let tailMessages = Array(messages.suffix(syncCount))
let layoutStart = CACurrentMediaTime()
calculateLayouts(messageSubset: tailMessages)
let layoutMs = (CACurrentMediaTime() - layoutStart) * 1000
Self.perfLog.info("⚡ layoutSync: \(syncCount)/\(self.messages.count) msgs in \(String(format: "%.0f", layoutMs))ms (windowed)")
// Async: full array with correct neighbor context for BubblePosition.
// Overwrites the sync-computed layouts (fixes boundary BubblePosition).
calculateLayoutsAsync()
} else {
// 25 messages sync all (fast enough, <75ms)
let layoutStart = CACurrentMediaTime()
calculateLayouts()
let layoutMs = (CACurrentMediaTime() - layoutStart) * 1000
Self.perfLog.info("⚡ layoutSync: \(self.messages.count)/\(self.messages.count) msgs in \(String(format: "%.0f", layoutMs))ms (all)")
}
} else if isInteractive { } else if isInteractive {
// Interactive insert (1-3 messages): sync layout so no delayed reconfigure // Interactive insert (1-3 messages): sync layout so no delayed reconfigure
var dirtyIds = newIds var dirtyIds = newIds
@@ -1537,17 +1557,16 @@ final class NativeMessageListController: UIViewController {
if i < messages.count - 1 { dirtyIds.insert(messages[i + 1].id) } if i < messages.count - 1 { dirtyIds.insert(messages[i + 1].id) }
} }
calculateLayouts(dirtyIds: dirtyIds) calculateLayouts(dirtyIds: dirtyIds)
} else if !newIds.isEmpty && newIds.count <= 20 { } else if !newIds.isEmpty {
// Incremental non-interactive: async on background // Non-interactive update (pagination, sync): sync layout for new messages + neighbors.
// Cells MUST have layouts before snapshot async leaves them empty (race condition).
// ~52 CoreText measurements 30-50ms. Cache bounded by maxCacheSize (200).
var dirtyIds = newIds var dirtyIds = newIds
for i in messages.indices where newIds.contains(messages[i].id) { for i in messages.indices where newIds.contains(messages[i].id) {
if i > 0 { dirtyIds.insert(messages[i - 1].id) } if i > 0 { dirtyIds.insert(messages[i - 1].id) }
if i < messages.count - 1 { dirtyIds.insert(messages[i + 1].id) } if i < messages.count - 1 { dirtyIds.insert(messages[i + 1].id) }
} }
calculateLayoutsAsync(dirtyIds: dirtyIds) calculateLayouts(dirtyIds: dirtyIds)
} else if !newIds.isEmpty {
// Bulk update (pagination, sync): async full recalculation
calculateLayoutsAsync()
} }
// else: newIds is empty no new messages, skip layout recalculation. // else: newIds is empty no new messages, skip layout recalculation.
// Prevents Combine debounce duplicate from killing insertion animations // Prevents Combine debounce duplicate from killing insertion animations
@@ -1591,7 +1610,12 @@ final class NativeMessageListController: UIViewController {
} }
} }
let snapStart = CACurrentMediaTime()
dataSource.apply(snapshot, animatingDifferences: false) dataSource.apply(snapshot, animatingDifferences: false)
if !hasCompletedInitialLoad {
let snapMs = (CACurrentMediaTime() - snapStart) * 1000
Self.perfLog.info("⚡ snapshot.apply: \(itemIds.count) items in \(String(format: "%.0f", snapMs))ms")
}
// Voice send correlation: fire deferred collapse when the voice cell appears. // Voice send correlation: fire deferred collapse when the voice cell appears.
// Handled AFTER layout settles (below), regardless of isInteractive. // Handled AFTER layout settles (below), regardless of isInteractive.
@@ -1668,7 +1692,6 @@ final class NativeMessageListController: UIViewController {
private func applyInsertionAnimations(newIds: Set<String>, oldPositions: [String: CGFloat]) { private func applyInsertionAnimations(newIds: Set<String>, oldPositions: [String: CGFloat]) {
for ip in collectionView.indexPathsForVisibleItems { for ip in collectionView.indexPathsForVisibleItems {
guard let cellId = dataSource.itemIdentifier(for: ip), guard let cellId = dataSource.itemIdentifier(for: ip),
cellId != Self.unreadSeparatorId,
let cell = collectionView.cellForItem(at: ip) else { continue } let cell = collectionView.cellForItem(at: ip) else { continue }
if newIds.contains(cellId) { if newIds.contains(cellId) {
@@ -1702,15 +1725,17 @@ final class NativeMessageListController: UIViewController {
/// Recalculate layouts for messages. When `dirtyIds` is provided, only those /// Recalculate layouts for messages. When `dirtyIds` is provided, only those
/// messages are recalculated (incremental mode). Otherwise recalculates all. /// messages are recalculated (incremental mode). Otherwise recalculates all.
private func calculateLayouts(dirtyIds: Set<String>? = nil) { /// `messageSubset` overrides the message array (used for windowed initial sync).
guard !messages.isEmpty else { private func calculateLayouts(dirtyIds: Set<String>? = nil, messageSubset: [ChatMessage]? = nil) {
let msgs = messageSubset ?? messages
guard !msgs.isEmpty else {
layoutCache.removeAll() layoutCache.removeAll()
textLayoutCache.removeAll() textLayoutCache.removeAll()
return return
} }
let isDark = cachedIsDarkMode let isDark = cachedIsDarkMode
let (layouts, textLayouts) = MessageCellLayout.batchCalculate( let (layouts, textLayouts) = MessageCellLayout.batchCalculate(
messages: messages, messages: msgs,
maxBubbleWidth: config.maxBubbleWidth, maxBubbleWidth: config.maxBubbleWidth,
currentPublicKey: config.currentPublicKey, currentPublicKey: config.currentPublicKey,
opponentPublicKey: config.opponentPublicKey, opponentPublicKey: config.opponentPublicKey,
@@ -1750,8 +1775,12 @@ final class NativeMessageListController: UIViewController {
existingTextLayouts: dirtyIds != nil ? textLayoutCache : nil existingTextLayouts: dirtyIds != nil ? textLayoutCache : nil
) )
let asyncStart = CACurrentMediaTime()
let msgCount = messages.count
Task { @MainActor [weak self] in Task { @MainActor [weak self] in
let result = await LayoutEngine.shared.calculate(request) let result = await LayoutEngine.shared.calculate(request)
let asyncMs = (CACurrentMediaTime() - asyncStart) * 1000
Self.perfLog.info("⚡ layoutAsync: \(msgCount) msgs in \(String(format: "%.0f", asyncMs))ms (background)")
guard let self, result.generation >= self.layoutGeneration else { return } guard let self, result.generation >= self.layoutGeneration else { return }
self.layoutGeneration = result.generation self.layoutGeneration = result.generation
self.layoutCache = result.layouts self.layoutCache = result.layouts

View File

@@ -676,8 +676,8 @@ final class ChatListCell: UICollectionViewCell {
animateBadgeTransition(view: mentionImageView, shouldShow: showMention, wasVisible: &wasMentionBadgeVisible) animateBadgeTransition(view: mentionImageView, shouldShow: showMention, wasVisible: &wasMentionBadgeVisible)
} }
/// Telegram badge animation: appear = scale 0.00011.2 (0.2s) 1.0 (0.12s settle); /// Telegram badge animation: appear = CASpringAnimation pop-in; disappear = scale down.
/// disappear = scale 1.00.0001 (0.12s). Uses transform (not frame) + .allowUserInteraction. /// Uses Core Animation (not UIView.animate) so animations work inside performWithoutAnimation.
private func animateBadgeTransition(view: UIView, shouldShow: Bool, wasVisible: inout Bool) { private func animateBadgeTransition(view: UIView, shouldShow: Bool, wasVisible: inout Bool) {
let wasShowing = wasVisible let wasShowing = wasVisible
wasVisible = shouldShow wasVisible = shouldShow
@@ -686,26 +686,36 @@ final class ChatListCell: UICollectionViewCell {
view.layer.removeAllAnimations() view.layer.removeAllAnimations()
if shouldShow && !wasShowing { if shouldShow && !wasShowing {
// Appear: pop in with bounce // Appear: spring pop-in (CASpringAnimation bypasses performWithoutAnimation)
view.isHidden = false view.isHidden = false
view.transform = CGAffineTransform(scaleX: 0.0001, y: 0.0001) view.transform = .identity
UIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseOut, .allowUserInteraction]) {
view.transform = CGAffineTransform(scaleX: 1.15, y: 1.15) let spring = CASpringAnimation(keyPath: "transform.scale")
} completion: { _ in spring.fromValue = 0.0001
UIView.animate(withDuration: 0.12, delay: 0, options: [.curveEaseInOut, .allowUserInteraction]) { spring.toValue = 1.0
view.transform = .identity spring.mass = 1.0
} spring.stiffness = 400
} spring.damping = 22
spring.duration = spring.settlingDuration
view.layer.add(spring, forKey: "badgePop")
} else if !shouldShow && wasShowing { } else if !shouldShow && wasShowing {
// Disappear: scale down // Disappear: quick scale down
UIView.animate(withDuration: 0.12, delay: 0, options: [.curveEaseIn, .allowUserInteraction]) { let anim = CABasicAnimation(keyPath: "transform.scale")
view.transform = CGAffineTransform(scaleX: 0.0001, y: 0.0001) anim.fromValue = 1.0
} completion: { finished in anim.toValue = 0.0001
if finished { anim.duration = 0.12
view.isHidden = true anim.timingFunction = CAMediaTimingFunction(name: .easeIn)
view.transform = .identity anim.fillMode = .forwards
} anim.isRemovedOnCompletion = false
CATransaction.begin()
CATransaction.setCompletionBlock { [weak view] in
view?.isHidden = true
view?.transform = .identity
view?.layer.removeAnimation(forKey: "badgeHide")
} }
view.layer.add(anim, forKey: "badgeHide")
CATransaction.commit()
} else { } else {
// No transition just set visibility // No transition just set visibility
view.isHidden = !shouldShow view.isHidden = !shouldShow