RosettaListView подключен к ChatDetailViewController через флаг useRosettaListView

This commit is contained in:
2026-04-18 13:17:50 +05:00
parent 162bf5f5ef
commit 0e26e1e121
6 changed files with 119 additions and 37 deletions

View File

@@ -833,11 +833,11 @@ extension MessageCellLayout {
if config.hasReplyQuote { textY = replyH + topPad } if config.hasReplyQuote { textY = replyH + topPad }
if forwardHeaderH > 0 { textY = forwardHeaderH + 2 } if forwardHeaderH > 0 { textY = forwardHeaderH + 2 }
if photoH > 0 { if photoH > 0 {
// Photo has 2pt inset top + 2pt inset bottom, so text starts after photoH + 4 + gap // Photo has 2pt inset top + 2pt inset bottom, so text starts after photoH + 4 + gap.
textY = photoH + 4 + 3 + topPad // forwardHeaderH must be included photo is positioned at forwardHeaderH + 2 (see photoY below).
if config.hasReplyQuote { textY = replyH + photoH + 4 + 3 + topPad } textY = forwardHeaderH + (config.hasReplyQuote ? replyH : 0) + photoH + 4 + 3 + topPad
} }
if fileH > 0 { textY = fileH + (config.hasReplyQuote ? replyH : 0) + topPad } if fileH > 0 { textY = forwardHeaderH + (config.hasReplyQuote ? replyH : 0) + fileH + topPad }
if isShortSingleLineText && timestampInline { if isShortSingleLineText && timestampInline {
// Optical centering for short one-line text without inflating bubble height. // Optical centering for short one-line text without inflating bubble height.

View File

@@ -1498,13 +1498,24 @@ private extension ChatDetailView {
let replyAttachments: [ReplyAttachmentData] = message.attachments.compactMap { att in let replyAttachments: [ReplyAttachmentData] = message.attachments.compactMap { att in
// Skip MESSAGES attachments in nested replies (don't nest replies recursively) // Skip MESSAGES attachments in nested replies (don't nest replies recursively)
guard att.type != .messages else { return nil } guard att.type != .messages else { return nil }
// Extract transport tag: prefer transportTag, fall back to preview-embedded tag.
// effectiveDownloadTag only accepts UUID-format tags server may use short IDs.
let tag: String
if !att.transportTag.isEmpty {
tag = att.transportTag
} else {
let parts = att.preview.components(separatedBy: "::")
tag = (parts.count >= 2 && !parts[0].isEmpty) ? parts[0] : ""
}
// Strip tag prefix from preview tag is in transport object.
// Desktop expects raw payload (blurhash for images, size::name for files).
return ReplyAttachmentData( return ReplyAttachmentData(
id: att.id, id: att.id,
type: att.type.rawValue, type: att.type.rawValue,
preview: att.preview, preview: AttachmentPreviewCodec.payload(from: att.preview),
blob: "", // Blob cleared for reply (desktop parity) blob: "", // Blob cleared for reply (desktop parity)
transport: ReplyAttachmentTransport( transport: ReplyAttachmentTransport(
transport_tag: att.transportTag, transport_tag: tag,
transport_server: att.transportServer transport_server: att.transportServer
) )
) )

View File

@@ -20,7 +20,12 @@ final class ChatDetailViewController: UIViewController {
// MARK: - Child VC // MARK: - Child VC
/// Toggle to test RosettaListView vs UICollectionView.
/// Set to `true` to use the new custom ListView.
private static let useRosettaListView = true
private var messageListController: NativeMessageListController! private var messageListController: NativeMessageListController!
private var rosettaListController: RosettaMessageListController?
private let cellActions = MessageCellActions() private let cellActions = MessageCellActions()
// MARK: - State (mirrors SwiftUI @State) // MARK: - State (mirrors SwiftUI @State)
@@ -118,7 +123,11 @@ final class ChatDetailViewController: UIViewController {
})?.id })?.id
setupWallpaper() setupWallpaper()
if Self.useRosettaListView {
setupRosettaListController()
} else {
setupMessageListController() setupMessageListController()
}
setupNavigationChrome() setupNavigationChrome()
setupEdgeEffects() setupEdgeEffects()
wireCellActions() wireCellActions()
@@ -286,6 +295,59 @@ final class ChatDetailViewController: UIViewController {
// No reparenting needed pills are naturally covered by the composer. // No reparenting needed pills are naturally covered by the composer.
} }
/// Alternative setup using custom RosettaListView (Telegram-parity).
/// Activated by `useRosettaListView = true`.
private func setupRosettaListController() {
let config = RosettaMessageListController.Config(
maxBubbleWidth: calculateMaxBubbleWidth(),
currentPublicKey: currentPublicKey,
opponentPublicKey: route.publicKey,
opponentTitle: route.title,
isGroupChat: route.isGroup,
groupAdminKey: "",
actions: cellActions
)
let controller = RosettaMessageListController(config: config)
controller.hasMoreMessages = viewModel.hasMoreMessages
// Wire callbacks
controller.onScrollToBottomVisibilityChange = { [weak self] atBottom in
self?.isAtBottom = atBottom
}
controller.onPaginationTrigger = { [weak self] in
Task { await self?.viewModel.loadMore() }
}
controller.onJumpToBottom = { [weak self] in
self?.viewModel.jumpToBottom()
}
controller.onTapBackground = { [weak self] in
self?.view.endEditing(true)
}
// Add as child VC
addChild(controller)
controller.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(controller.view)
NSLayoutConstraint.activate([
controller.view.topAnchor.constraint(equalTo: view.topAnchor),
controller.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
controller.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
controller.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
controller.didMove(toParent: self)
controller.loadViewIfNeeded()
rosettaListController = controller
// Seed messages
let initialMessages = viewModel.messages.filter { !$0.text.hasPrefix("$a=") }
if !initialMessages.isEmpty {
controller.update(messages: initialMessages)
lastMessageFingerprint = messageFingerprint(viewModel.messages)
}
}
// MARK: - Toolbar Setup (glass capsules as direct subviews) // MARK: - Toolbar Setup (glass capsules as direct subviews)
private func setupNavigationChrome() { private func setupNavigationChrome() {
@@ -524,14 +586,27 @@ final class ChatDetailViewController: UIViewController {
} }
private func handleMessagesUpdate(_ messages: [ChatMessage]) { private func handleMessagesUpdate(_ messages: [ChatMessage]) {
// Filter out service messages ($a=) for display they're only for chat list
let visibleMessages = messages.filter { !$0.text.hasPrefix("$a=") }
// Route to active list controller
if Self.useRosettaListView {
guard let rc = rosettaListController else { return }
rc.hasMoreMessages = viewModel.hasMoreMessages
rc.hasNewerMessages = viewModel.hasNewerMessages
let fingerprint = messageFingerprint(messages)
guard fingerprint != lastMessageFingerprint else { return }
rc.update(messages: visibleMessages)
lastMessageFingerprint = fingerprint
lastNewestMessageId = visibleMessages.last?.id
return
}
guard let controller = messageListController else { return } guard let controller = messageListController else { return }
controller.hasMoreMessages = viewModel.hasMoreMessages controller.hasMoreMessages = viewModel.hasMoreMessages
controller.hasNewerMessages = viewModel.hasNewerMessages controller.hasNewerMessages = viewModel.hasNewerMessages
// Filter out service messages ($a=) for display they're only for chat list
let visibleMessages = messages.filter { !$0.text.hasPrefix("$a=") }
// Empty state: based on visible (non-service) messages // Empty state: based on visible (non-service) messages
controller.updateEmptyState(isEmpty: visibleMessages.isEmpty, info: makeEmptyChatInfo()) controller.updateEmptyState(isEmpty: visibleMessages.isEmpty, info: makeEmptyChatInfo())
@@ -1716,13 +1791,27 @@ final class ChatDetailViewController: UIViewController {
private func buildReplyData(from message: ChatMessage) -> ReplyMessageData { private func buildReplyData(from message: ChatMessage) -> ReplyMessageData {
let replyAttachments: [ReplyAttachmentData] = message.attachments.compactMap { att in let replyAttachments: [ReplyAttachmentData] = message.attachments.compactMap { att in
guard att.type != .messages else { return nil } guard att.type != .messages else { return nil }
// Extract transport tag: prefer transportTag, fall back to preview-embedded tag.
// effectiveDownloadTag only accepts UUID-format tags server may use short IDs.
let tag: String
if !att.transportTag.isEmpty {
tag = att.transportTag
} else {
let parts = att.preview.components(separatedBy: "::")
tag = (parts.count >= 2 && !parts[0].isEmpty) ? parts[0] : ""
}
#if DEBUG
print("🔍 buildReplyData att.id=\(att.id) transportTag='\(att.transportTag)' transportServer='\(att.transportServer)' preview='\(att.preview.prefix(40))' → tag='\(tag)'")
#endif
// Strip tag prefix from preview tag is in transport object.
// Desktop expects raw payload (blurhash for images, size::name for files).
return ReplyAttachmentData( return ReplyAttachmentData(
id: att.id, id: att.id,
type: att.type.rawValue, type: att.type.rawValue,
preview: att.preview, preview: AttachmentPreviewCodec.payload(from: att.preview),
blob: "", blob: "",
transport: ReplyAttachmentTransport( transport: ReplyAttachmentTransport(
transport_tag: att.transportTag, transport_tag: tag,
transport_server: att.transportServer transport_server: att.transportServer
) )
) )

View File

@@ -1,4 +1,3 @@
import AudioToolbox
import AVFAudio import AVFAudio
@preconcurrency import AVFoundation @preconcurrency import AVFoundation
import Lottie import Lottie
@@ -1179,22 +1178,10 @@ extension ComposerView: RecordingMicButtonDelegate {
isRecording = true isRecording = true
isRecordingLocked = false isRecordingLocked = false
setRecordingFlowState(.recordingUnlocked) setRecordingFlowState(.recordingUnlocked)
// Haptic is fired by RecordingMicButton.beginRecording() (prepared generator)
// BEFORE this delegate call so it fires before AVAudioSession starts.
presentRecordingChrome(locked: false, animatePanel: true) presentRecordingChrome(locked: false, animatePanel: true)
// Haptic 100ms after chrome overlay is at alpha ~0.7, visually present.
// Fired outside the Task so AVAudioSession can't suppress it, and
// button state guards can't skip it.
let hapticGenerator = UIImpactFeedbackGenerator(style: .medium)
hapticGenerator.prepare()
print("[HAPTIC] prepare() called, scheduling impactOccurred in 100ms")
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
let isStillRecording = self?.isRecording == true
print("[HAPTIC] firing impactOccurred — isRecording=\(isStillRecording) flowState=\(String(describing: self?.recordingFlowState))")
hapticGenerator.impactOccurred()
AudioServicesPlaySystemSound(1519)
print("[HAPTIC] impactOccurred + SystemSound(1519) DONE")
}
recordingStartTask?.cancel() recordingStartTask?.cancel()
recordingStartTask = Task { @MainActor [weak self] in recordingStartTask = Task { @MainActor [weak self] in
guard let self else { return } guard let self else { return }

View File

@@ -947,7 +947,7 @@ final class NativeMessageListController: UIViewController {
var sectionMap: [String: (topY: CGFloat, bottomY: CGFloat)] = [:] var sectionMap: [String: (topY: CGFloat, bottomY: CGFloat)] = [:]
for cell in collectionView.visibleCells { for cell in collectionView.visibleCells {
guard cell is NativeMessageCell else { continue } guard messageCell(from: cell) != nil else { continue }
let cellFrame = collectionView.convert(cell.frame, to: view) let cellFrame = collectionView.convert(cell.frame, to: view)
guard let ip = collectionView.indexPath(for: cell), guard let ip = collectionView.indexPath(for: cell),

View File

@@ -1,4 +1,3 @@
import AudioToolbox
import QuartzCore import QuartzCore
import UIKit import UIKit
@@ -127,7 +126,6 @@ final class RecordingMicButton: UIControl {
didLockHaptic = false didLockHaptic = false
impactFeedback.prepare() impactFeedback.prepare()
print("[HAPTIC-MIC] beginTracking — prepare() called")
recordingDelegate?.micButtonRecordingArmed(self) recordingDelegate?.micButtonRecordingArmed(self)
// Start hold timer after 0.19s we begin recording // Start hold timer after 0.19s we begin recording
@@ -257,14 +255,13 @@ final class RecordingMicButton: UIControl {
// MARK: - State Transitions // MARK: - State Transitions
private func beginRecording() { private func beginRecording() {
guard recordingState == .waiting else { guard recordingState == .waiting else { return }
print("[HAPTIC-MIC] beginRecording SKIPPED — state=\(recordingState)")
return
}
recordingState = .recording recordingState = .recording
holdTimer = nil holdTimer = nil
print("[HAPTIC-MIC] beginRecording — calling delegate")
// Haptic fires BEFORE delegate delegate starts AVAudioSession
// which suppresses Taptic Engine.
fireHaptic()
startDisplayLink() startDisplayLink()
recordingDelegate?.micButtonRecordingBegan(self) recordingDelegate?.micButtonRecordingBegan(self)
} }
@@ -343,9 +340,7 @@ final class RecordingMicButton: UIControl {
/// UIFeedbackGenerator API and hits Taptic Engine directly. /// UIFeedbackGenerator API and hits Taptic Engine directly.
/// Telegram uses this as fallback in HapticFeedback.swift. /// Telegram uses this as fallback in HapticFeedback.swift.
private func fireHaptic() { private func fireHaptic() {
print("[HAPTIC-MIC] fireHaptic() — state=\(recordingState)")
impactFeedback.impactOccurred() impactFeedback.impactOccurred()
AudioServicesPlaySystemSound(1519)
impactFeedback.prepare() impactFeedback.prepare()
} }