diff --git a/Rosetta/Features/Chats/ChatDetail/ListView/RosettaMessageListController.swift b/Rosetta/Features/Chats/ChatDetail/ListView/RosettaMessageListController.swift new file mode 100644 index 0000000..262a56c --- /dev/null +++ b/Rosetta/Features/Chats/ChatDetail/ListView/RosettaMessageListController.swift @@ -0,0 +1,131 @@ +import UIKit +import os + +// MARK: - RosettaMessageListController + +/// Chat message list using custom RosettaListView (Telegram-parity). +/// Replaces NativeMessageListController (UICollectionView-based). +/// +/// Phase 1: Basic message display + scroll. No keyboard, date pills, selection. +@MainActor +final class RosettaMessageListController: UIViewController { + + private static let perfLog = Logger(subsystem: "com.rosetta.messenger", category: "RosettaList") + + // MARK: - Configuration + + struct Config { + var maxBubbleWidth: CGFloat + var currentPublicKey: String + var opponentPublicKey: String + var opponentTitle: String + var isGroupChat: Bool + var groupAdminKey: String + var actions: MessageCellActions + } + + private let config: Config + + // MARK: - Views + + private let listView = RosettaListView(frame: .zero) + + // MARK: - State + + private var messages: [ChatMessage] = [] + private var layoutCache: [String: MessageCellLayout] = [:] + private var textLayoutCache: [String: CoreTextTextLayout] = [:] + + // MARK: - Init + + init(config: Config) { + self.config = config + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .clear + + listView.frame = view.bounds + listView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + listView.stackFromBottom = true + view.addSubview(listView) + } + + // MARK: - Public API + + /// Update displayed messages. Calculates layouts and builds list items. + func update(messages: [ChatMessage]) { + let start = CACurrentMediaTime() + self.messages = messages + + // Calculate layouts for new messages (reuse cached) + calculateLayouts() + + // Build list items + var items: [RosettaListItem] = [] + for message in messages { + guard let layout = layoutCache[message.id] else { continue } + let textLayout = textLayoutCache[message.id] + let item = ChatMessageListItem( + message: message, + layout: layout, + textLayout: textLayout, + timestampText: formatTimestamp(message.timestamp), + actions: config.actions + ) + items.append(item) + } + + listView.setItems(items) + + let ms = (CACurrentMediaTime() - start) * 1000 + Self.perfLog.notice("⚡ RosettaList.update: \(messages.count, privacy: .public) msgs in \(String(format: "%.0f", ms), privacy: .public)ms") + } + + /// Scroll to newest message. + func scrollToBottom(animated: Bool) { + listView.scrollToBottom(animated: animated) + } + + // MARK: - Layout Calculation + + private func calculateLayouts() { + let isDark = traitCollection.userInterfaceStyle == .dark + let (layouts, textLayouts) = MessageCellLayout.batchCalculate( + messages: messages, + maxBubbleWidth: config.maxBubbleWidth, + currentPublicKey: config.currentPublicKey, + opponentPublicKey: config.opponentPublicKey, + opponentTitle: config.opponentTitle, + isGroupChat: config.isGroupChat, + groupAdminKey: config.groupAdminKey, + isDarkMode: isDark, + dirtyIds: nil, + existingLayouts: layoutCache.isEmpty ? nil : layoutCache, + existingTextLayouts: textLayoutCache.isEmpty ? nil : textLayoutCache + ) + layoutCache = layouts + textLayoutCache = textLayouts + } + + // MARK: - Timestamp Formatting + + private static let timestampFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "HH:mm" + f.locale = .autoupdatingCurrent + f.timeZone = .autoupdatingCurrent + return f + }() + + private func formatTimestamp(_ ms: Int64) -> String { + Self.timestampFormatter.string(from: Date(timeIntervalSince1970: Double(ms) / 1000)) + } +}