Убран текст Message у пересланных голосовых + ширина bubble по duration
This commit is contained in:
@@ -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 */
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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]) {
|
||||||
|
return (TimeInterval(dur), parts.dropFirst(2).joined(separator: "::"))
|
||||||
} else if let dur = Int(parts[0]) {
|
} 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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
// 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()
|
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
|
||||||
|
|||||||
@@ -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.0001→1.2 (0.2s) → 1.0 (0.12s settle);
|
/// Telegram badge animation: appear = CASpringAnimation pop-in; disappear = scale down.
|
||||||
/// disappear = scale 1.0→0.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)
|
|
||||||
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
|
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 {
|
} 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
|
||||||
|
|||||||
Reference in New Issue
Block a user