Убран текст Message у пересланных голосовых + ширина bubble по duration
This commit is contained in:
@@ -949,7 +949,7 @@
|
||||
C19929D9466573F31997B2C0 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
853F295D2F4B50410092AD05 /* Build configuration list for PBXProject "Rosetta" */ = {
|
||||
isa = XCConfigurationList;
|
||||
@@ -958,7 +958,7 @@
|
||||
853F296C2F4B50420092AD05 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
853F296D2F4B50420092AD05 /* Build configuration list for PBXNativeTarget "Rosetta" */ = {
|
||||
isa = XCConfigurationList;
|
||||
@@ -967,7 +967,7 @@
|
||||
853F296F2F4B50420092AD05 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
A8D200712F9000010092AD05 /* Build configuration list for PBXNativeTarget "RosettaUITests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
@@ -976,7 +976,7 @@
|
||||
A8D200622F9000010092AD05 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
B5D2E60ADEB8AE2E8F7615C6 /* Build configuration list for PBXNativeTarget "RosettaNotificationService" */ = {
|
||||
isa = XCConfigurationList;
|
||||
@@ -985,7 +985,7 @@
|
||||
0140D6320A9CF4B5E933E0B1 /* Debug */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
LA00000062F8D22220092AD05 /* Build configuration list for PBXNativeTarget "RosettaLiveActivityWidget" */ = {
|
||||
isa = XCConfigurationList;
|
||||
@@ -994,7 +994,7 @@
|
||||
LA00000082F8D22220092AD05 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Release"
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
|
||||
@@ -1180,6 +1180,32 @@ 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.
|
||||
/// Returns both frame layouts AND cached CoreTextTextLayouts for cell rendering.
|
||||
/// Telegram equivalent: ListView calls asyncLayout() on background.
|
||||
@@ -1198,19 +1224,9 @@ extension MessageCellLayout {
|
||||
) -> (layouts: [String: MessageCellLayout], textLayouts: [String: CoreTextTextLayout]) {
|
||||
var result: [String: MessageCellLayout] = existingLayouts ?? [:]
|
||||
var textResult: [String: CoreTextTextLayout] = existingTextLayouts ?? [:]
|
||||
let timestampFormatter = DateFormatter()
|
||||
timestampFormatter.dateFormat = "HH:mm"
|
||||
timestampFormatter.locale = .autoupdatingCurrent
|
||||
timestampFormatter.timeZone = .autoupdatingCurrent
|
||||
|
||||
let calendar = Calendar.current
|
||||
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() {
|
||||
// Incremental: skip messages not in dirty set (reuse existing cache)
|
||||
@@ -1219,7 +1235,7 @@ extension MessageCellLayout {
|
||||
}
|
||||
let isOutgoing = message.fromPublicKey == currentPublicKey
|
||||
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
|
||||
let showsDateHeader: Bool
|
||||
@@ -1231,8 +1247,8 @@ extension MessageCellLayout {
|
||||
}
|
||||
let dateHeaderText = showsDateHeader
|
||||
? Self.formatDateHeader(messageDate, now: now, calendar: calendar,
|
||||
sameYearFormatter: sameYearFormatter,
|
||||
diffYearFormatter: diffYearFormatter)
|
||||
sameYearFormatter: Self.sameYearHeaderFmt,
|
||||
diffYearFormatter: Self.diffYearHeaderFmt)
|
||||
: ""
|
||||
|
||||
// Filter garbage/encrypted text (UIKit path parity with SwiftUI MessageCellView)
|
||||
@@ -1298,6 +1314,7 @@ extension MessageCellLayout {
|
||||
var forwardInnerImageCount = 0
|
||||
var forwardInnerFileCount = 0
|
||||
var forwardInnerVoiceCount = 0
|
||||
var forwardVoiceDuration: TimeInterval = 0
|
||||
var forwardSenderName = ""
|
||||
var forwardChachaKeyPlain = ""
|
||||
var forwardAttachments: [ReplyAttachmentData] = []
|
||||
@@ -1314,6 +1331,18 @@ extension MessageCellLayout {
|
||||
forwardInnerImageCount = first.attachments.filter { $0.type == 0 }.count
|
||||
forwardInnerFileCount = first.attachments.filter { $0.type == 2 }.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
|
||||
forwardAttachments = first.attachments
|
||||
// Resolve forward sender name for dynamic bubble width
|
||||
@@ -1394,7 +1423,7 @@ extension MessageCellLayout {
|
||||
avatarCount: avatars.count,
|
||||
callCount: calls.count,
|
||||
voiceCount: voices.count,
|
||||
voiceDuration: voiceDuration,
|
||||
voiceDuration: voiceDuration > 0 ? voiceDuration : forwardVoiceDuration,
|
||||
isForward: isForward,
|
||||
forwardImageCount: forwardInnerImageCount,
|
||||
forwardFileCount: forwardInnerFileCount,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import UIKit
|
||||
import Combine
|
||||
import SwiftUI
|
||||
import os
|
||||
|
||||
// MARK: - ChatDetailViewController
|
||||
|
||||
@@ -9,6 +10,8 @@ import SwiftUI
|
||||
/// Phase 1: shell + child VC + state + callbacks + Combine subscriptions.
|
||||
final class ChatDetailViewController: UIViewController {
|
||||
|
||||
private static let perfLog = Logger(subsystem: "com.rosetta.messenger", category: "ChatOpen")
|
||||
|
||||
// MARK: - Route & ViewModel
|
||||
|
||||
let route: ChatRoute
|
||||
@@ -104,6 +107,7 @@ final class ChatDetailViewController: UIViewController {
|
||||
// MARK: - Lifecycle
|
||||
|
||||
override func viewDidLoad() {
|
||||
let vdlStart = CACurrentMediaTime()
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = UIColor { $0.userInterfaceStyle == .dark ? .black : .white }
|
||||
|
||||
@@ -119,6 +123,8 @@ final class ChatDetailViewController: UIViewController {
|
||||
setupEdgeEffects()
|
||||
wireCellActions()
|
||||
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
|
||||
if pendingForwardData != nil {
|
||||
@@ -259,9 +265,14 @@ final class ChatDetailViewController: UIViewController {
|
||||
// skeleton flash even when messages are cached.
|
||||
let initialMessages = viewModel.messages.filter { !$0.text.hasPrefix("$a=") }
|
||||
if !initialMessages.isEmpty {
|
||||
let seedStart = CACurrentMediaTime()
|
||||
controller.updateEmptyState(isEmpty: false, info: makeEmptyChatInfo())
|
||||
controller.update(messages: initialMessages)
|
||||
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
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
import os
|
||||
import QuartzCore
|
||||
|
||||
/// Per-dialog observation isolation for ChatDetailView.
|
||||
///
|
||||
@@ -28,15 +30,25 @@ final class ChatDetailViewModel: ObservableObject {
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
private static let perfLog = Logger(subsystem: "com.rosetta.messenger", category: "ChatOpen")
|
||||
|
||||
init(dialogKey: String) {
|
||||
self.dialogKey = dialogKey
|
||||
|
||||
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
|
||||
let vmStart = CACurrentMediaTime()
|
||||
let initial = repo.messages(for: dialogKey)
|
||||
messages = initial
|
||||
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.
|
||||
// Otherwise keep isLoading=true until first Combine emission or timeout.
|
||||
isLoading = initial.isEmpty
|
||||
|
||||
@@ -136,6 +136,9 @@ final class MessageVoiceView: UIView {
|
||||
width: max(0, waveW),
|
||||
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
|
||||
durationLabel.frame = CGRect(
|
||||
@@ -164,8 +167,17 @@ final class MessageVoiceView: UIView {
|
||||
|
||||
self.totalDuration = duration
|
||||
|
||||
// Decode waveform from preview
|
||||
let samples = Self.decodeWaveform(from: preview)
|
||||
// Decode waveform from preview — fallback to flat bars if empty
|
||||
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.progress = 0
|
||||
|
||||
|
||||
@@ -1872,15 +1872,25 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
/// Parse voice preview: "tag::duration::waveform" or "duration::waveform"
|
||||
static func parseVoicePreview(_ preview: String) -> (duration: TimeInterval, waveform: String) {
|
||||
let parts = preview.components(separatedBy: "::")
|
||||
// Format: "tag::duration::waveform" or "duration::waveform"
|
||||
if parts.count >= 3, let dur = Int(parts[1]) {
|
||||
return (TimeInterval(dur), parts[2])
|
||||
} else if parts.count >= 2, let dur = Int(parts[0]) {
|
||||
return (TimeInterval(dur), parts[1])
|
||||
// Format: "tag::duration::waveform" or "duration::waveform" or "::duration::waveform"
|
||||
// Try 3+ parts: tag::duration::waveform (or leading empty ::duration::waveform)
|
||||
if parts.count >= 3 {
|
||||
// Check parts[1] first (tag::dur::wave), then parts[0] if that fails (edge cases)
|
||||
if let dur = Int(parts[1]) {
|
||||
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 (0, preview)
|
||||
return (0, "")
|
||||
}
|
||||
|
||||
private static func resolvePlayableVoiceURL(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Combine
|
||||
import os
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
@@ -23,6 +24,8 @@ struct ReplyDataCacheEntry {
|
||||
@MainActor
|
||||
final class NativeMessageListController: UIViewController {
|
||||
|
||||
private static let perfLog = Logger(subsystem: "com.rosetta.messenger", category: "ChatOpen")
|
||||
|
||||
private enum UIConstants {
|
||||
static let messageToComposerGap: CGFloat = 16
|
||||
static let scrollButtonSize: CGFloat = 40
|
||||
@@ -1498,10 +1501,9 @@ final class NativeMessageListController: UIViewController {
|
||||
: []
|
||||
if isInteractive {
|
||||
for ip in collectionView.indexPathsForVisibleItems {
|
||||
if let msgId = messageId(for: ip),
|
||||
let cell = collectionView.cellForItem(at: ip) {
|
||||
oldPositions[msgId] = cell.layer.position.y
|
||||
}
|
||||
guard let itemId = dataSource.itemIdentifier(for: ip),
|
||||
let cell = collectionView.cellForItem(at: ip) else { continue }
|
||||
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
|
||||
// reconfigureVisibleCells() from calculateLayoutsAsync killing animations.
|
||||
if layoutCache.isEmpty {
|
||||
// First load: synchronous to avoid blank cells
|
||||
// First load: sync-calculate only the visible tail (newest messages
|
||||
// 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 {
|
||||
// Interactive insert (1-3 messages): sync layout so no delayed reconfigure
|
||||
var dirtyIds = newIds
|
||||
@@ -1537,17 +1557,16 @@ final class NativeMessageListController: UIViewController {
|
||||
if i < messages.count - 1 { dirtyIds.insert(messages[i + 1].id) }
|
||||
}
|
||||
calculateLayouts(dirtyIds: dirtyIds)
|
||||
} else if !newIds.isEmpty && newIds.count <= 20 {
|
||||
// Incremental non-interactive: async on background
|
||||
} else if !newIds.isEmpty {
|
||||
// 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
|
||||
for i in messages.indices where newIds.contains(messages[i].id) {
|
||||
if i > 0 { dirtyIds.insert(messages[i - 1].id) }
|
||||
if i < messages.count - 1 { dirtyIds.insert(messages[i + 1].id) }
|
||||
}
|
||||
calculateLayoutsAsync(dirtyIds: dirtyIds)
|
||||
} else if !newIds.isEmpty {
|
||||
// Bulk update (pagination, sync): async full recalculation
|
||||
calculateLayoutsAsync()
|
||||
calculateLayouts(dirtyIds: dirtyIds)
|
||||
}
|
||||
// else: newIds is empty — no new messages, skip layout recalculation.
|
||||
// Prevents Combine debounce duplicate from killing insertion animations
|
||||
@@ -1591,7 +1610,12 @@ final class NativeMessageListController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
let snapStart = CACurrentMediaTime()
|
||||
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.
|
||||
// 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]) {
|
||||
for ip in collectionView.indexPathsForVisibleItems {
|
||||
guard let cellId = dataSource.itemIdentifier(for: ip),
|
||||
cellId != Self.unreadSeparatorId,
|
||||
let cell = collectionView.cellForItem(at: ip) else { continue }
|
||||
|
||||
if newIds.contains(cellId) {
|
||||
@@ -1702,15 +1725,17 @@ final class NativeMessageListController: UIViewController {
|
||||
|
||||
/// Recalculate layouts for messages. When `dirtyIds` is provided, only those
|
||||
/// messages are recalculated (incremental mode). Otherwise recalculates all.
|
||||
private func calculateLayouts(dirtyIds: Set<String>? = nil) {
|
||||
guard !messages.isEmpty else {
|
||||
/// `messageSubset` overrides the message array (used for windowed initial sync).
|
||||
private func calculateLayouts(dirtyIds: Set<String>? = nil, messageSubset: [ChatMessage]? = nil) {
|
||||
let msgs = messageSubset ?? messages
|
||||
guard !msgs.isEmpty else {
|
||||
layoutCache.removeAll()
|
||||
textLayoutCache.removeAll()
|
||||
return
|
||||
}
|
||||
let isDark = cachedIsDarkMode
|
||||
let (layouts, textLayouts) = MessageCellLayout.batchCalculate(
|
||||
messages: messages,
|
||||
messages: msgs,
|
||||
maxBubbleWidth: config.maxBubbleWidth,
|
||||
currentPublicKey: config.currentPublicKey,
|
||||
opponentPublicKey: config.opponentPublicKey,
|
||||
@@ -1750,8 +1775,12 @@ final class NativeMessageListController: UIViewController {
|
||||
existingTextLayouts: dirtyIds != nil ? textLayoutCache : nil
|
||||
)
|
||||
|
||||
let asyncStart = CACurrentMediaTime()
|
||||
let msgCount = messages.count
|
||||
Task { @MainActor [weak self] in
|
||||
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 }
|
||||
self.layoutGeneration = result.generation
|
||||
self.layoutCache = result.layouts
|
||||
|
||||
@@ -676,8 +676,8 @@ final class ChatListCell: UICollectionViewCell {
|
||||
animateBadgeTransition(view: mentionImageView, shouldShow: showMention, wasVisible: &wasMentionBadgeVisible)
|
||||
}
|
||||
|
||||
/// Telegram badge animation: appear = scale 0.0001→1.2 (0.2s) → 1.0 (0.12s settle);
|
||||
/// disappear = scale 1.0→0.0001 (0.12s). Uses transform (not frame) + .allowUserInteraction.
|
||||
/// Telegram badge animation: appear = CASpringAnimation pop-in; disappear = scale down.
|
||||
/// Uses Core Animation (not UIView.animate) so animations work inside performWithoutAnimation.
|
||||
private func animateBadgeTransition(view: UIView, shouldShow: Bool, wasVisible: inout Bool) {
|
||||
let wasShowing = wasVisible
|
||||
wasVisible = shouldShow
|
||||
@@ -686,26 +686,36 @@ final class ChatListCell: UICollectionViewCell {
|
||||
view.layer.removeAllAnimations()
|
||||
|
||||
if shouldShow && !wasShowing {
|
||||
// Appear: pop in with bounce
|
||||
// Appear: spring pop-in (CASpringAnimation bypasses performWithoutAnimation)
|
||||
view.isHidden = false
|
||||
view.transform = CGAffineTransform(scaleX: 0.0001, y: 0.0001)
|
||||
UIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseOut, .allowUserInteraction]) {
|
||||
view.transform = CGAffineTransform(scaleX: 1.15, y: 1.15)
|
||||
} completion: { _ in
|
||||
UIView.animate(withDuration: 0.12, delay: 0, options: [.curveEaseInOut, .allowUserInteraction]) {
|
||||
view.transform = .identity
|
||||
}
|
||||
}
|
||||
|
||||
let spring = CASpringAnimation(keyPath: "transform.scale")
|
||||
spring.fromValue = 0.0001
|
||||
spring.toValue = 1.0
|
||||
spring.mass = 1.0
|
||||
spring.stiffness = 400
|
||||
spring.damping = 22
|
||||
spring.duration = spring.settlingDuration
|
||||
view.layer.add(spring, forKey: "badgePop")
|
||||
} else if !shouldShow && wasShowing {
|
||||
// Disappear: scale down
|
||||
UIView.animate(withDuration: 0.12, delay: 0, options: [.curveEaseIn, .allowUserInteraction]) {
|
||||
view.transform = CGAffineTransform(scaleX: 0.0001, y: 0.0001)
|
||||
} completion: { finished in
|
||||
if finished {
|
||||
view.isHidden = true
|
||||
view.transform = .identity
|
||||
}
|
||||
// Disappear: quick scale down
|
||||
let anim = CABasicAnimation(keyPath: "transform.scale")
|
||||
anim.fromValue = 1.0
|
||||
anim.toValue = 0.0001
|
||||
anim.duration = 0.12
|
||||
anim.timingFunction = CAMediaTimingFunction(name: .easeIn)
|
||||
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 {
|
||||
// No transition — just set visibility
|
||||
view.isHidden = !shouldShow
|
||||
|
||||
Reference in New Issue
Block a user