Убран текст 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 */,
);
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 */

View File

@@ -66,7 +66,7 @@
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Release"
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -676,8 +676,8 @@ final class ChatListCell: UICollectionViewCell {
animateBadgeTransition(view: mentionImageView, shouldShow: showMention, wasVisible: &wasMentionBadgeVisible)
}
/// Telegram badge animation: appear = scale 0.00011.2 (0.2s) 1.0 (0.12s settle);
/// disappear = scale 1.00.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