| 1 | import LChatCore |
| 2 | import SwiftUI |
| 3 | import UIKit |
| 4 | |
| 5 | // The chat list, backed by a UICollectionView list instead of SwiftUI's List. |
| 6 | // |
| 7 | // Why UIKit: SwiftUI's List + NavigationLink doesn't reproduce UITableView's stock |
| 8 | // row behaviour — a quick tap shows no press highlight (the pressed state renders |
| 9 | // too late). A UICollectionView list gives it for free: a row highlights on a |
| 10 | // settled touch, stays selected through the push (fading on return), and never |
| 11 | // highlights when a finger lands and drags to scroll. Cells render the same SwiftUI |
| 12 | // ThreadRow via UIHostingConfiguration, so the look is unchanged. The SwiftUI |
| 13 | // NavigationStack / .searchable / toolbar scaffolding stays around this; it's only |
| 14 | // the scrolling content. (See the ios-native-uikit-direction note — NetNewsWire is |
| 15 | // the reference for feeling natively UIKit.) |
| 16 | struct ThreadListCollection: UIViewControllerRepresentable { |
| 17 | var threads: [ChatThread] |
| 18 | var onSelect: (String) -> Void |
| 19 | var onClose: (ChatThread) -> Void |
| 20 | var onPin: (ChatThread) -> Void |
| 21 | var onToggleUnread: (ChatThread) -> Void |
| 22 | var onMute: (ChatThread) -> Void |
| 23 | |
| 24 | func makeUIViewController(context: Context) -> ThreadListController { ThreadListController() } |
| 25 | |
| 26 | func updateUIViewController(_ vc: ThreadListController, context: Context) { |
| 27 | vc.onSelect = onSelect |
| 28 | vc.onClose = onClose |
| 29 | vc.onPin = onPin |
| 30 | vc.onToggleUnread = onToggleUnread |
| 31 | vc.onMute = onMute |
| 32 | vc.apply(threads) |
| 33 | } |
| 34 | } |
| 35 | |
| 36 | final class ThreadListController: UICollectionViewController { |
| 37 | private enum Section { case main } |
| 38 | |
| 39 | var onSelect: ((String) -> Void)? |
| 40 | var onClose: ((ChatThread) -> Void)? |
| 41 | var onPin: ((ChatThread) -> Void)? |
| 42 | var onToggleUnread: ((ChatThread) -> Void)? |
| 43 | var onMute: ((ChatThread) -> Void)? |
| 44 | |
| 45 | private var threadsByID: [String: ChatThread] = [:] |
| 46 | private var latestThreads: [ChatThread] = [] |
| 47 | private var dataSource: UICollectionViewDiffableDataSource<Section, String>! |
| 48 | private var populated = false |
| 49 | // Mirrors the applied snapshot's row count for the separator handler. |
| 50 | private var itemCount = 0 |
| 51 | |
| 52 | init() { |
| 53 | // A placeholder list layout; viewDidLoad swaps in the real one, which needs |
| 54 | // `self` for the swipe provider (not referenceable before super.init). |
| 55 | super.init(collectionViewLayout: UICollectionViewCompositionalLayout.list( |
| 56 | using: UICollectionLayoutListConfiguration(appearance: .plain))) |
| 57 | // We deselect the pushed row ourselves in viewWillAppear so it fades out |
| 58 | // alongside the pop (UIKit's clearsSelection would drop it with no animation). |
| 59 | clearsSelectionOnViewWillAppear = false |
| 60 | } |
| 61 | required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } |
| 62 | |
| 63 | override func viewDidLoad() { |
| 64 | super.viewDidLoad() |
| 65 | collectionView.backgroundColor = .systemBackground |
| 66 | |
| 67 | var config = UICollectionLayoutListConfiguration(appearance: .plain) |
| 68 | // Native swipe-to-close — the list mirror of the in-chat "Close chat" — |
| 69 | // plus mute, Mail's trailing-cluster idiom (Close stays outermost, so |
| 70 | // a full swipe still closes). |
| 71 | config.trailingSwipeActionsConfigurationProvider = { [weak self] indexPath in |
| 72 | guard let self, |
| 73 | let id = self.dataSource.itemIdentifier(for: indexPath), |
| 74 | let thread = self.threadsByID[id] else { return nil } |
| 75 | let close = UIContextualAction(style: .destructive, title: "Close") { _, _, done in |
| 76 | self.onClose?(thread) |
| 77 | done(true) |
| 78 | } |
| 79 | close.image = UIImage(systemName: "xmark.circle") |
| 80 | let mute = UIContextualAction(style: .normal, |
| 81 | title: thread.isMuted ? "Unmute" : "Mute") { _, _, done in |
| 82 | self.onMute?(thread) |
| 83 | done(true) |
| 84 | } |
| 85 | mute.image = UIImage(systemName: thread.isMuted ? "bell" : "bell.slash") |
| 86 | mute.backgroundColor = .systemIndigo |
| 87 | return UISwipeActionsConfiguration(actions: [close, mute]) |
| 88 | } |
| 89 | // Swipe right: toggle read/unread — Mail's idiom. |
| 90 | config.leadingSwipeActionsConfigurationProvider = { [weak self] indexPath in |
| 91 | guard let self, |
| 92 | let id = self.dataSource.itemIdentifier(for: indexPath), |
| 93 | let thread = self.threadsByID[id] else { return nil } |
| 94 | let toggle = UIContextualAction(style: .normal, |
| 95 | title: thread.unread ? "Read" : "Unread") { _, _, done in |
| 96 | self.onToggleUnread?(thread) |
| 97 | done(true) |
| 98 | } |
| 99 | toggle.image = UIImage(systemName: thread.unread ? "envelope.open" : "envelope.badge") |
| 100 | toggle.backgroundColor = .systemBlue |
| 101 | return UISwipeActionsConfiguration(actions: [toggle]) |
| 102 | } |
| 103 | // Separators run only *between* rows: no line above the first, none below the |
| 104 | // last (a plain list would otherwise cap both ends). The count comes from |
| 105 | // the var apply() maintains — dataSource.snapshot() COPIES the whole |
| 106 | // snapshot, and this handler runs per row, so reading it here was O(n²) |
| 107 | // across a layout pass. |
| 108 | config.itemSeparatorHandler = { [weak self] indexPath, proposed in |
| 109 | var sep = proposed |
| 110 | if indexPath.item == 0 { sep.topSeparatorVisibility = .hidden } |
| 111 | if indexPath.item == (self?.itemCount ?? 0) - 1 { |
| 112 | sep.bottomSeparatorVisibility = .hidden |
| 113 | } |
| 114 | return sep |
| 115 | } |
| 116 | collectionView.setCollectionViewLayout( |
| 117 | UICollectionViewCompositionalLayout.list(using: config), animated: false) |
| 118 | |
| 119 | let cell = UICollectionView.CellRegistration<UICollectionViewListCell, String> { [weak self] cell, _, id in |
| 120 | guard let thread = self?.threadsByID[id] else { return } |
| 121 | cell.contentConfiguration = UIHostingConfiguration { ThreadRow(thread: thread) } |
| 122 | .margins(.vertical, 10) |
| 123 | cell.accessories = [.disclosureIndicator()] |
| 124 | } |
| 125 | dataSource = UICollectionViewDiffableDataSource<Section, String>(collectionView: collectionView) { |
| 126 | cv, indexPath, id in |
| 127 | cv.dequeueConfiguredReusableCell(using: cell, for: indexPath, item: id) |
| 128 | } |
| 129 | |
| 130 | apply(latestThreads) // data set before the view loaded |
| 131 | } |
| 132 | |
| 133 | override func viewWillAppear(_ animated: Bool) { |
| 134 | super.viewWillAppear(animated) |
| 135 | // Coming back from a chat: fade the row's selection out alongside the pop, |
| 136 | // and restore it if the back gesture is cancelled — the standard UIKit idiom. |
| 137 | guard let selected = collectionView.indexPathsForSelectedItems?.first else { return } |
| 138 | guard let coordinator = transitionCoordinator else { |
| 139 | collectionView.deselectItem(at: selected, animated: animated) |
| 140 | return |
| 141 | } |
| 142 | coordinator.animate(alongsideTransition: { _ in |
| 143 | self.collectionView.deselectItem(at: selected, animated: true) |
| 144 | }, completion: { ctx in |
| 145 | if ctx.isCancelled { |
| 146 | self.collectionView.selectItem(at: selected, animated: false, scrollPosition: []) |
| 147 | } |
| 148 | }) |
| 149 | } |
| 150 | |
| 151 | override func viewDidAppear(_ animated: Bool) { |
| 152 | super.viewDidAppear(animated) |
| 153 | // Let SwiftUI's navigation bar track our (UIKit) scroll view so its scroll-edge |
| 154 | // appearance transitions correctly (transparent at the top, material once |
| 155 | // scrolled). The bar reads the top view controller's content scroll view; ours |
| 156 | // lives in a child VC, so hand it to the nearest ancestor the nav controller manages. |
| 157 | var top: UIViewController = self |
| 158 | while let parent = top.parent, !(parent is UINavigationController) { top = parent } |
| 159 | top.setContentScrollView(collectionView, for: .top) |
| 160 | } |
| 161 | |
| 162 | func apply(_ threads: [ChatThread]) { |
| 163 | latestThreads = threads |
| 164 | guard isViewLoaded, dataSource != nil else { return } // deferred to viewDidLoad |
| 165 | let previous = threadsByID |
| 166 | threadsByID = Dictionary(threads.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a }) |
| 167 | let ids = threads.map(\.id) |
| 168 | itemCount = ids.count |
| 169 | var snap = NSDiffableDataSourceSnapshot<Section, String>() |
| 170 | snap.appendSections([.main]) |
| 171 | snap.appendItems(ids, toSection: .main) |
| 172 | // Surviving rows re-render (fresh preview / time / unread dot) — but |
| 173 | // only the ones that actually changed: each reconfigure rebuilds a |
| 174 | // UIHostingConfiguration on the main thread, and this runs on every |
| 175 | // store publish, so one flipped unread dot must not re-host every |
| 176 | // row (#465). |
| 177 | let changed = ids.filter { id in |
| 178 | guard let old = previous[id] else { return false } // new row: the snapshot inserts it |
| 179 | return old != threadsByID[id] |
| 180 | } |
| 181 | if !changed.isEmpty { snap.reconfigureItems(changed) } |
| 182 | dataSource.apply(snap, animatingDifferences: populated) |
| 183 | populated = true |
| 184 | } |
| 185 | |
| 186 | override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { |
| 187 | guard let id = dataSource.itemIdentifier(for: indexPath) else { return } |
| 188 | onSelect?(id) // pushes via the SwiftUI NavigationStack; the row stays selected until we return |
| 189 | } |
| 190 | |
| 191 | // Long-press: the row's action menu (pin / unread toggle / close) — the |
| 192 | // stock UIKit interaction, matching the Mac list's right-click menu. |
| 193 | override func collectionView(_ collectionView: UICollectionView, |
| 194 | contextMenuConfigurationForItemAt indexPath: IndexPath, |
| 195 | point: CGPoint) -> UIContextMenuConfiguration? { |
| 196 | guard let id = dataSource.itemIdentifier(for: indexPath), |
| 197 | let thread = threadsByID[id] else { return nil } |
| 198 | return UIContextMenuConfiguration(actionProvider: { _ in |
| 199 | UIMenu(children: [ |
| 200 | UIAction(title: thread.pinned ? "Unpin" : "Pin", |
| 201 | image: UIImage(systemName: thread.pinned ? "pin.slash" : "pin")) { [weak self] _ in |
| 202 | self?.onPin?(thread) |
| 203 | }, |
| 204 | UIAction(title: thread.isMuted ? "Unmute chat" : "Mute chat", |
| 205 | image: UIImage(systemName: thread.isMuted ? "bell" : "bell.slash")) { [weak self] _ in |
| 206 | self?.onMute?(thread) |
| 207 | }, |
| 208 | UIAction(title: thread.unread ? "Mark as Read" : "Mark as Unread", |
| 209 | image: UIImage(systemName: thread.unread ? "envelope.open" : "envelope.badge")) { [weak self] _ in |
| 210 | self?.onToggleUnread?(thread) |
| 211 | }, |
| 212 | UIAction(title: "Close", image: UIImage(systemName: "xmark.circle"), |
| 213 | attributes: .destructive) { [weak self] _ in |
| 214 | self?.onClose?(thread) |
| 215 | }, |
| 216 | ]) |
| 217 | }) |
| 218 | } |
| 219 | } |
| 220 | |