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