RosettaListView подключен к ChatDetailViewController через флаг useRosettaListView
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user