diff --git a/Rosetta/Core/Layout/MessageCellLayout.swift b/Rosetta/Core/Layout/MessageCellLayout.swift index bc10b06..4dac74e 100644 --- a/Rosetta/Core/Layout/MessageCellLayout.swift +++ b/Rosetta/Core/Layout/MessageCellLayout.swift @@ -833,11 +833,11 @@ extension MessageCellLayout { if config.hasReplyQuote { textY = replyH + topPad } if forwardHeaderH > 0 { textY = forwardHeaderH + 2 } if photoH > 0 { - // Photo has 2pt inset top + 2pt inset bottom, so text starts after photoH + 4 + gap - textY = photoH + 4 + 3 + topPad - if config.hasReplyQuote { textY = replyH + photoH + 4 + 3 + topPad } + // Photo has 2pt inset top + 2pt inset bottom, so text starts after photoH + 4 + gap. + // forwardHeaderH must be included — photo is positioned at forwardHeaderH + 2 (see photoY below). + 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 { // Optical centering for short one-line text without inflating bubble height. diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift index 699360b..fc88be8 100644 --- a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift @@ -1498,13 +1498,24 @@ private extension ChatDetailView { let replyAttachments: [ReplyAttachmentData] = message.attachments.compactMap { att in // Skip MESSAGES attachments in nested replies (don't nest replies recursively) 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( id: att.id, type: att.type.rawValue, - preview: att.preview, + preview: AttachmentPreviewCodec.payload(from: att.preview), blob: "", // Blob cleared for reply (desktop parity) transport: ReplyAttachmentTransport( - transport_tag: att.transportTag, + transport_tag: tag, transport_server: att.transportServer ) ) diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailViewController.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailViewController.swift index c6db143..dfe6aa2 100644 --- a/Rosetta/Features/Chats/ChatDetail/ChatDetailViewController.swift +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailViewController.swift @@ -20,7 +20,12 @@ final class ChatDetailViewController: UIViewController { // 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 rosettaListController: RosettaMessageListController? private let cellActions = MessageCellActions() // MARK: - State (mirrors SwiftUI @State) @@ -118,7 +123,11 @@ final class ChatDetailViewController: UIViewController { })?.id setupWallpaper() - setupMessageListController() + if Self.useRosettaListView { + setupRosettaListController() + } else { + setupMessageListController() + } setupNavigationChrome() setupEdgeEffects() wireCellActions() @@ -286,6 +295,59 @@ final class ChatDetailViewController: UIViewController { // 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) private func setupNavigationChrome() { @@ -524,14 +586,27 @@ final class ChatDetailViewController: UIViewController { } 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 } controller.hasMoreMessages = viewModel.hasMoreMessages 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 controller.updateEmptyState(isEmpty: visibleMessages.isEmpty, info: makeEmptyChatInfo()) @@ -1716,13 +1791,27 @@ final class ChatDetailViewController: UIViewController { private func buildReplyData(from message: ChatMessage) -> ReplyMessageData { let replyAttachments: [ReplyAttachmentData] = message.attachments.compactMap { att in 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( id: att.id, type: att.type.rawValue, - preview: att.preview, + preview: AttachmentPreviewCodec.payload(from: att.preview), blob: "", transport: ReplyAttachmentTransport( - transport_tag: att.transportTag, + transport_tag: tag, transport_server: att.transportServer ) ) diff --git a/Rosetta/Features/Chats/ChatDetail/ComposerView.swift b/Rosetta/Features/Chats/ChatDetail/ComposerView.swift index 49fb58c..0f7a417 100644 --- a/Rosetta/Features/Chats/ChatDetail/ComposerView.swift +++ b/Rosetta/Features/Chats/ChatDetail/ComposerView.swift @@ -1,4 +1,3 @@ -import AudioToolbox import AVFAudio @preconcurrency import AVFoundation import Lottie @@ -1179,22 +1178,10 @@ extension ComposerView: RecordingMicButtonDelegate { isRecording = true isRecordingLocked = false 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) - // 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 = Task { @MainActor [weak self] in guard let self else { return } diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift index 3c6a2c2..35d3957 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift @@ -947,7 +947,7 @@ final class NativeMessageListController: UIViewController { var sectionMap: [String: (topY: CGFloat, bottomY: CGFloat)] = [:] 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) guard let ip = collectionView.indexPath(for: cell), diff --git a/Rosetta/Features/Chats/ChatDetail/RecordingMicButton.swift b/Rosetta/Features/Chats/ChatDetail/RecordingMicButton.swift index 19fb45f..6d7ce75 100644 --- a/Rosetta/Features/Chats/ChatDetail/RecordingMicButton.swift +++ b/Rosetta/Features/Chats/ChatDetail/RecordingMicButton.swift @@ -1,4 +1,3 @@ -import AudioToolbox import QuartzCore import UIKit @@ -127,7 +126,6 @@ final class RecordingMicButton: UIControl { didLockHaptic = false impactFeedback.prepare() - print("[HAPTIC-MIC] beginTracking — prepare() called") recordingDelegate?.micButtonRecordingArmed(self) // Start hold timer — after 0.19s we begin recording @@ -257,14 +255,13 @@ final class RecordingMicButton: UIControl { // MARK: - State Transitions private func beginRecording() { - guard recordingState == .waiting else { - print("[HAPTIC-MIC] beginRecording SKIPPED — state=\(recordingState)") - return - } + guard recordingState == .waiting else { return } recordingState = .recording holdTimer = nil - print("[HAPTIC-MIC] beginRecording — calling delegate") + // Haptic fires BEFORE delegate — delegate starts AVAudioSession + // which suppresses Taptic Engine. + fireHaptic() startDisplayLink() recordingDelegate?.micButtonRecordingBegan(self) } @@ -343,9 +340,7 @@ final class RecordingMicButton: UIControl { /// UIFeedbackGenerator API and hits Taptic Engine directly. /// Telegram uses this as fallback in HapticFeedback.swift. private func fireHaptic() { - print("[HAPTIC-MIC] fireHaptic() — state=\(recordingState)") impactFeedback.impactOccurred() - AudioServicesPlaySystemSound(1519) impactFeedback.prepare() }