import LChatCore import SwiftUI import UIKit // The chat list, backed by a UICollectionView list instead of SwiftUI's List. // // Why UIKit: SwiftUI's List + NavigationLink doesn't reproduce UITableView's stock // row behaviour — a quick tap shows no press highlight (the pressed state renders // too late). A UICollectionView list gives it for free: a row highlights on a // settled touch, stays selected through the push (fading on return), and never // highlights when a finger lands and drags to scroll. Cells render the same SwiftUI // ThreadRow via UIHostingConfiguration, so the look is unchanged. The SwiftUI // NavigationStack / .searchable / toolbar scaffolding stays around this; it's only // the scrolling content. (See the ios-native-uikit-direction note — NetNewsWire is // the reference for feeling natively UIKit.) struct ThreadListCollection: UIViewControllerRepresentable { var threads: [ChatThread] var onSelect: (String) -> Void var onClose: (ChatThread) -> Void var onPin: (ChatThread) -> Void var onToggleUnread: (ChatThread) -> Void var onMute: (ChatThread) -> Void func makeUIViewController(context: Context) -> ThreadListController { ThreadListController() } func updateUIViewController(_ vc: ThreadListController, context: Context) { vc.onSelect = onSelect vc.onClose = onClose vc.onPin = onPin vc.onToggleUnread = onToggleUnread vc.onMute = onMute vc.apply(threads) } } final class ThreadListController: UICollectionViewController { private enum Section { case main } var onSelect: ((String) -> Void)? var onClose: ((ChatThread) -> Void)? var onPin: ((ChatThread) -> Void)? var onToggleUnread: ((ChatThread) -> Void)? var onMute: ((ChatThread) -> Void)? private var threadsByID: [String: ChatThread] = [:] private var latestThreads: [ChatThread] = [] private var dataSource: UICollectionViewDiffableDataSource! private var populated = false // Mirrors the applied snapshot's row count for the separator handler. private var itemCount = 0 init() { // A placeholder list layout; viewDidLoad swaps in the real one, which needs // `self` for the swipe provider (not referenceable before super.init). super.init(collectionViewLayout: UICollectionViewCompositionalLayout.list( using: UICollectionLayoutListConfiguration(appearance: .plain))) // We deselect the pushed row ourselves in viewWillAppear so it fades out // alongside the pop (UIKit's clearsSelection would drop it with no animation). clearsSelectionOnViewWillAppear = false } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() collectionView.backgroundColor = .systemBackground var config = UICollectionLayoutListConfiguration(appearance: .plain) // Native swipe-to-close — the list mirror of the in-chat "Close chat" — // plus mute, Mail's trailing-cluster idiom (Close stays outermost, so // a full swipe still closes). config.trailingSwipeActionsConfigurationProvider = { [weak self] indexPath in guard let self, let id = self.dataSource.itemIdentifier(for: indexPath), let thread = self.threadsByID[id] else { return nil } let close = UIContextualAction(style: .destructive, title: "Close") { _, _, done in self.onClose?(thread) done(true) } close.image = UIImage(systemName: "xmark.circle") let mute = UIContextualAction(style: .normal, title: thread.isMuted ? "Unmute" : "Mute") { _, _, done in self.onMute?(thread) done(true) } mute.image = UIImage(systemName: thread.isMuted ? "bell" : "bell.slash") mute.backgroundColor = .systemIndigo return UISwipeActionsConfiguration(actions: [close, mute]) } // Swipe right: toggle read/unread — Mail's idiom. config.leadingSwipeActionsConfigurationProvider = { [weak self] indexPath in guard let self, let id = self.dataSource.itemIdentifier(for: indexPath), let thread = self.threadsByID[id] else { return nil } let toggle = UIContextualAction(style: .normal, title: thread.unread ? "Read" : "Unread") { _, _, done in self.onToggleUnread?(thread) done(true) } toggle.image = UIImage(systemName: thread.unread ? "envelope.open" : "envelope.badge") toggle.backgroundColor = .systemBlue return UISwipeActionsConfiguration(actions: [toggle]) } // Separators run only *between* rows: no line above the first, none below the // last (a plain list would otherwise cap both ends). The count comes from // the var apply() maintains — dataSource.snapshot() COPIES the whole // snapshot, and this handler runs per row, so reading it here was O(n²) // across a layout pass. config.itemSeparatorHandler = { [weak self] indexPath, proposed in var sep = proposed if indexPath.item == 0 { sep.topSeparatorVisibility = .hidden } if indexPath.item == (self?.itemCount ?? 0) - 1 { sep.bottomSeparatorVisibility = .hidden } return sep } collectionView.setCollectionViewLayout( UICollectionViewCompositionalLayout.list(using: config), animated: false) let cell = UICollectionView.CellRegistration { [weak self] cell, _, id in guard let thread = self?.threadsByID[id] else { return } cell.contentConfiguration = UIHostingConfiguration { ThreadRow(thread: thread) } .margins(.vertical, 10) cell.accessories = [.disclosureIndicator()] } dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { cv, indexPath, id in cv.dequeueConfiguredReusableCell(using: cell, for: indexPath, item: id) } apply(latestThreads) // data set before the view loaded } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) // Coming back from a chat: fade the row's selection out alongside the pop, // and restore it if the back gesture is cancelled — the standard UIKit idiom. guard let selected = collectionView.indexPathsForSelectedItems?.first else { return } guard let coordinator = transitionCoordinator else { collectionView.deselectItem(at: selected, animated: animated) return } coordinator.animate(alongsideTransition: { _ in self.collectionView.deselectItem(at: selected, animated: true) }, completion: { ctx in if ctx.isCancelled { self.collectionView.selectItem(at: selected, animated: false, scrollPosition: []) } }) } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) // Let SwiftUI's navigation bar track our (UIKit) scroll view so its scroll-edge // appearance transitions correctly (transparent at the top, material once // scrolled). The bar reads the top view controller's content scroll view; ours // lives in a child VC, so hand it to the nearest ancestor the nav controller manages. var top: UIViewController = self while let parent = top.parent, !(parent is UINavigationController) { top = parent } top.setContentScrollView(collectionView, for: .top) } func apply(_ threads: [ChatThread]) { latestThreads = threads guard isViewLoaded, dataSource != nil else { return } // deferred to viewDidLoad let previous = threadsByID threadsByID = Dictionary(threads.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a }) let ids = threads.map(\.id) itemCount = ids.count var snap = NSDiffableDataSourceSnapshot() snap.appendSections([.main]) snap.appendItems(ids, toSection: .main) // Surviving rows re-render (fresh preview / time / unread dot) — but // only the ones that actually changed: each reconfigure rebuilds a // UIHostingConfiguration on the main thread, and this runs on every // store publish, so one flipped unread dot must not re-host every // row (#465). let changed = ids.filter { id in guard let old = previous[id] else { return false } // new row: the snapshot inserts it return old != threadsByID[id] } if !changed.isEmpty { snap.reconfigureItems(changed) } dataSource.apply(snap, animatingDifferences: populated) populated = true } override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { guard let id = dataSource.itemIdentifier(for: indexPath) else { return } onSelect?(id) // pushes via the SwiftUI NavigationStack; the row stays selected until we return } // Long-press: the row's action menu (pin / unread toggle / close) — the // stock UIKit interaction, matching the Mac list's right-click menu. override func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { guard let id = dataSource.itemIdentifier(for: indexPath), let thread = threadsByID[id] else { return nil } return UIContextMenuConfiguration(actionProvider: { _ in UIMenu(children: [ UIAction(title: thread.pinned ? "Unpin" : "Pin", image: UIImage(systemName: thread.pinned ? "pin.slash" : "pin")) { [weak self] _ in self?.onPin?(thread) }, UIAction(title: thread.isMuted ? "Unmute chat" : "Mute chat", image: UIImage(systemName: thread.isMuted ? "bell" : "bell.slash")) { [weak self] _ in self?.onMute?(thread) }, UIAction(title: thread.unread ? "Mark as Read" : "Mark as Unread", image: UIImage(systemName: thread.unread ? "envelope.open" : "envelope.badge")) { [weak self] _ in self?.onToggleUnread?(thread) }, UIAction(title: "Close", image: UIImage(systemName: "xmark.circle"), attributes: .destructive) { [weak self] _ in self?.onClose?(thread) }, ]) }) } }