729 lines
28 KiB
Swift
729 lines
28 KiB
Swift
import UIKit
|
|
import SwiftUI
|
|
|
|
// MARK: - ChatListCollectionController
|
|
|
|
/// UIViewController hosting a UICollectionView for the chat list.
|
|
/// Uses DiffableDataSource for smooth animated updates and manual-frame ChatListCell
|
|
/// for Telegram-level scroll performance.
|
|
///
|
|
/// Integrates into SwiftUI via `ChatListCollectionView` (UIViewControllerRepresentable).
|
|
final class ChatListCollectionController: UIViewController {
|
|
|
|
// MARK: - Sections
|
|
|
|
enum Section: Int, CaseIterable {
|
|
case requests
|
|
case pinned
|
|
case unpinned
|
|
}
|
|
|
|
// MARK: - Callbacks (to SwiftUI)
|
|
|
|
var onSelectDialog: ((Dialog) -> Void)?
|
|
var onDeleteDialog: ((Dialog) -> Void)?
|
|
var onTogglePin: ((Dialog) -> Void)?
|
|
var onToggleMute: ((Dialog) -> Void)?
|
|
var onPinnedStateChange: ((Bool) -> Void)?
|
|
var onPinnedHeaderFractionChange: ((CGFloat) -> Void)?
|
|
var onShowRequests: (() -> Void)?
|
|
var onScrollToTopRequested: (() -> Void)?
|
|
var onScrollOffsetChange: ((CGFloat) -> Void)?
|
|
var onMarkAsRead: ((Dialog) -> Void)?
|
|
|
|
// MARK: - Data
|
|
|
|
private(set) var pinnedDialogs: [Dialog] = []
|
|
private(set) var unpinnedDialogs: [Dialog] = []
|
|
private(set) var requestsCount: Int = 0
|
|
private(set) var typingDialogs: [String: Set<String>] = [:]
|
|
private(set) var isSyncing: Bool = false
|
|
private var lastReportedExpansion: CGFloat = 1.0
|
|
private var lastReportedPinnedHeaderFraction: CGFloat = -1.0
|
|
private let searchCollapseDistance: CGFloat = 54
|
|
/// Extra top offset for custom header bar (nav bar is hidden)
|
|
var customHeaderBarHeight: CGFloat = 44
|
|
private var searchHeaderExpansion: CGFloat = 1.0
|
|
private var hasInitializedTopOffset = false
|
|
private var isPinnedFractionReportScheduled = false
|
|
|
|
// MARK: - UI
|
|
|
|
private var collectionView: UICollectionView!
|
|
private var dataSource: UICollectionViewDiffableDataSource<Section, String>!
|
|
/// Overscroll background — fills rubber-band area with pinnedItemBackgroundColor (Telegram parity)
|
|
private let overscrollBackgroundView = UIView()
|
|
private var cellRegistration: UICollectionView.CellRegistration<ChatListCell, Dialog>!
|
|
private var requestsCellRegistration: UICollectionView.CellRegistration<ChatListRequestsCell, Int>!
|
|
private let floatingTabBarTotalHeight: CGFloat = 72
|
|
private var chatListBottomInset: CGFloat {
|
|
floatingTabBarTotalHeight
|
|
}
|
|
|
|
// Dialog lookup by ID for cell configuration
|
|
private var dialogMap: [String: Dialog] = [:]
|
|
|
|
// MARK: - Lifecycle
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
view.backgroundColor = UIColor(RosettaColors.Adaptive.background)
|
|
setupCollectionView()
|
|
setupCellRegistrations()
|
|
setupDataSource()
|
|
setupScrollToTop()
|
|
}
|
|
|
|
deinit {
|
|
NotificationCenter.default.removeObserver(self, name: .chatListScrollToTop, object: nil)
|
|
}
|
|
|
|
// MARK: - Collection View Setup
|
|
|
|
private func setupCollectionView() {
|
|
let layout = createLayout()
|
|
collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
|
|
collectionView.translatesAutoresizingMaskIntoConstraints = false
|
|
collectionView.backgroundColor = UIColor(RosettaColors.Adaptive.background)
|
|
collectionView.delegate = self
|
|
collectionView.prefetchDataSource = self
|
|
collectionView.keyboardDismissMode = .onDrag
|
|
collectionView.showsVerticalScrollIndicator = false
|
|
collectionView.showsHorizontalScrollIndicator = false
|
|
collectionView.alwaysBounceVertical = true
|
|
collectionView.alwaysBounceHorizontal = false
|
|
collectionView.contentInsetAdjustmentBehavior = .never
|
|
applyInsets()
|
|
view.addSubview(collectionView)
|
|
|
|
// Overscroll background — behind cells, shows pinnedItemBackgroundColor on pull-down bounce
|
|
overscrollBackgroundView.isUserInteractionEnabled = false
|
|
overscrollBackgroundView.isHidden = true
|
|
collectionView.insertSubview(overscrollBackgroundView, at: 0)
|
|
|
|
NSLayoutConstraint.activate([
|
|
collectionView.topAnchor.constraint(equalTo: view.topAnchor),
|
|
collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
|
collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
|
])
|
|
}
|
|
|
|
override func viewDidLayoutSubviews() {
|
|
super.viewDidLayoutSubviews()
|
|
applyInsets()
|
|
if !hasInitializedTopOffset {
|
|
collectionView.setContentOffset(
|
|
CGPoint(x: 0, y: -collectionView.contentInset.top),
|
|
animated: false
|
|
)
|
|
hasInitializedTopOffset = true
|
|
}
|
|
schedulePinnedHeaderFractionReport()
|
|
}
|
|
|
|
override func viewSafeAreaInsetsDidChange() {
|
|
super.viewSafeAreaInsetsDidChange()
|
|
applyInsets()
|
|
}
|
|
|
|
private func applyInsets() {
|
|
guard collectionView != nil else { return }
|
|
let oldTopInset = collectionView.contentInset.top
|
|
let topInset = view.safeAreaInsets.top + customHeaderBarHeight + (searchCollapseDistance * searchHeaderExpansion)
|
|
let bottomInset = chatListBottomInset
|
|
collectionView.contentInset.top = topInset
|
|
collectionView.contentInset.bottom = bottomInset
|
|
collectionView.verticalScrollIndicatorInsets.top = topInset
|
|
collectionView.verticalScrollIndicatorInsets.bottom = bottomInset
|
|
|
|
guard hasInitializedTopOffset,
|
|
!collectionView.isDragging,
|
|
!collectionView.isDecelerating else { return }
|
|
|
|
let delta = topInset - oldTopInset
|
|
if abs(delta) > 0.1 {
|
|
CATransaction.begin()
|
|
CATransaction.setDisableActions(true)
|
|
collectionView.contentOffset.y -= delta
|
|
CATransaction.commit()
|
|
}
|
|
}
|
|
|
|
func setSearchHeaderExpansion(_ expansion: CGFloat) {
|
|
let clamped = max(0.0, min(1.0, expansion))
|
|
guard abs(searchHeaderExpansion - clamped) > 0.002 else { return }
|
|
|
|
CATransaction.begin()
|
|
CATransaction.setDisableActions(true)
|
|
searchHeaderExpansion = clamped
|
|
applyInsets()
|
|
CATransaction.commit()
|
|
reportPinnedHeaderFraction()
|
|
}
|
|
|
|
private func updateOverscrollBackground() {
|
|
let hasPin = !pinnedDialogs.isEmpty
|
|
overscrollBackgroundView.isHidden = !hasPin
|
|
guard hasPin, collectionView != nil else { return }
|
|
|
|
let isDark = traitCollection.userInterfaceStyle == .dark
|
|
overscrollBackgroundView.backgroundColor = isDark
|
|
? UIColor(red: 0x1C / 255, green: 0x1C / 255, blue: 0x1D / 255, alpha: 1)
|
|
: UIColor(red: 0xF7 / 255, green: 0xF7 / 255, blue: 0xF7 / 255, alpha: 1)
|
|
|
|
let offset = collectionView.contentOffset.y
|
|
let insetTop = collectionView.contentInset.top
|
|
let overscrollAmount = -(offset + insetTop)
|
|
let width = collectionView.bounds.width
|
|
|
|
if overscrollAmount > 0 {
|
|
overscrollBackgroundView.frame = CGRect(
|
|
x: 0, y: offset,
|
|
width: width, height: insetTop + overscrollAmount
|
|
)
|
|
} else {
|
|
overscrollBackgroundView.frame = CGRect(
|
|
x: 0, y: -insetTop,
|
|
width: width, height: insetTop
|
|
)
|
|
}
|
|
}
|
|
|
|
private func schedulePinnedHeaderFractionReport(force: Bool = false) {
|
|
if isPinnedFractionReportScheduled { return }
|
|
isPinnedFractionReportScheduled = true
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let self else { return }
|
|
self.isPinnedFractionReportScheduled = false
|
|
self.reportPinnedHeaderFraction(force: force)
|
|
}
|
|
}
|
|
|
|
private func calculatePinnedHeaderFraction() -> CGFloat {
|
|
guard collectionView != nil,
|
|
view.window != nil,
|
|
collectionView.window != nil,
|
|
!pinnedDialogs.isEmpty else { return 0.0 }
|
|
|
|
// Telegram: itemNode.frame is in VISUAL space (changes with scroll).
|
|
// UICollectionView cell.frame is in CONTENT space (static).
|
|
// Convert: visibleMaxY = cell.frame.maxY - contentOffset.y
|
|
let offset = collectionView.contentOffset.y
|
|
var maxPinnedVisibleOffset: CGFloat = 0.0
|
|
var foundAny = false
|
|
|
|
for cell in collectionView.visibleCells {
|
|
guard let indexPath = collectionView.indexPath(for: cell),
|
|
sectionForIndexPath(indexPath) == .pinned else {
|
|
continue
|
|
}
|
|
let visibleMaxY = cell.frame.maxY - offset
|
|
maxPinnedVisibleOffset = max(maxPinnedVisibleOffset, visibleMaxY)
|
|
foundAny = true
|
|
}
|
|
|
|
if !foundAny { return 0.0 }
|
|
|
|
let viewportInsetTop = collectionView.contentInset.top
|
|
guard viewportInsetTop > 0 else { return 0.0 }
|
|
|
|
if maxPinnedVisibleOffset >= viewportInsetTop {
|
|
return 1.0
|
|
}
|
|
|
|
return max(0.0, min(1.0, maxPinnedVisibleOffset / viewportInsetTop))
|
|
}
|
|
|
|
private func reportPinnedHeaderFraction(force: Bool = false) {
|
|
let fraction = calculatePinnedHeaderFraction()
|
|
if !force, abs(fraction - lastReportedPinnedHeaderFraction) < 0.005 {
|
|
return
|
|
}
|
|
lastReportedPinnedHeaderFraction = fraction
|
|
onPinnedHeaderFractionChange?(fraction)
|
|
}
|
|
|
|
private func createLayout() -> UICollectionViewCompositionalLayout {
|
|
var listConfig = UICollectionLayoutListConfiguration(appearance: .plain)
|
|
listConfig.showsSeparators = false
|
|
listConfig.backgroundColor = .clear
|
|
|
|
// Swipe actions
|
|
listConfig.trailingSwipeActionsConfigurationProvider = { [weak self] indexPath in
|
|
self?.trailingSwipeActions(for: indexPath)
|
|
}
|
|
listConfig.leadingSwipeActionsConfigurationProvider = { [weak self] indexPath in
|
|
self?.leadingSwipeActions(for: indexPath)
|
|
}
|
|
|
|
let layout = UICollectionViewCompositionalLayout { [weak self] sectionIndex, environment in
|
|
let section = NSCollectionLayoutSection.list(using: listConfig, layoutEnvironment: environment)
|
|
section.interGroupSpacing = 0
|
|
|
|
// Add pinned section background decoration to pinned AND requests sections
|
|
// (requests sit above pinned, so they share the same gray background)
|
|
if let self,
|
|
sectionIndex < self.dataSource?.snapshot().sectionIdentifiers.count ?? 0,
|
|
!self.pinnedDialogs.isEmpty {
|
|
let sectionId = self.dataSource?.snapshot().sectionIdentifiers[sectionIndex]
|
|
if sectionId == .pinned || sectionId == .requests {
|
|
let bgItem = NSCollectionLayoutDecorationItem.background(
|
|
elementKind: PinnedSectionBackgroundView.elementKind
|
|
)
|
|
section.decorationItems = [bgItem]
|
|
}
|
|
}
|
|
|
|
return section
|
|
}
|
|
|
|
layout.register(
|
|
PinnedSectionBackgroundView.self,
|
|
forDecorationViewOfKind: PinnedSectionBackgroundView.elementKind
|
|
)
|
|
|
|
return layout
|
|
}
|
|
|
|
// MARK: - Cell Registrations
|
|
|
|
private func setupCellRegistrations() {
|
|
cellRegistration = UICollectionView.CellRegistration<ChatListCell, Dialog> {
|
|
[weak self] cell, indexPath, dialog in
|
|
guard let self else { return }
|
|
let typingUsers = self.typingDialogs[dialog.opponentKey]
|
|
cell.configure(with: dialog, isSyncing: self.isSyncing, typingUsers: typingUsers)
|
|
|
|
// Separator rules:
|
|
// 1) last pinned row -> full-width separator
|
|
// 2) last unpinned row -> hidden separator
|
|
// 3) others -> regular inset separator
|
|
let section = self.sectionForIndexPath(indexPath)
|
|
let isLastInPinned = section == .pinned && indexPath.item == self.pinnedDialogs.count - 1
|
|
let isLastInUnpinned = section == .unpinned && indexPath.item == self.unpinnedDialogs.count - 1
|
|
if isLastInPinned {
|
|
cell.setSeparatorStyle(hidden: false, fullWidth: true)
|
|
} else {
|
|
cell.setSeparatorStyle(hidden: isLastInUnpinned, fullWidth: false)
|
|
}
|
|
}
|
|
|
|
requestsCellRegistration = UICollectionView.CellRegistration<ChatListRequestsCell, Int> {
|
|
[weak self] cell, _, count in
|
|
// Always show separator under requests row
|
|
cell.configure(count: count, showBottomSeparator: true)
|
|
}
|
|
}
|
|
|
|
// MARK: - Data Source
|
|
|
|
private func setupDataSource() {
|
|
dataSource = UICollectionViewDiffableDataSource<Section, String>(
|
|
collectionView: collectionView
|
|
) { [weak self] collectionView, indexPath, itemId in
|
|
guard let self else { return UICollectionViewCell() }
|
|
|
|
// CRITICAL: use sectionIdentifier, NOT rawValue mapping.
|
|
// When sections are skipped (e.g. no requests), indexPath.section=0
|
|
// could be .pinned, not .requests. rawValue mapping would be wrong.
|
|
let section = self.dataSource.snapshot().sectionIdentifiers[indexPath.section]
|
|
|
|
if section == .requests {
|
|
return collectionView.dequeueConfiguredReusableCell(
|
|
using: self.requestsCellRegistration,
|
|
for: indexPath,
|
|
item: self.requestsCount
|
|
)
|
|
}
|
|
|
|
guard let dialog = self.dialogMap[itemId] else {
|
|
return UICollectionViewCell()
|
|
}
|
|
|
|
return collectionView.dequeueConfiguredReusableCell(
|
|
using: self.cellRegistration,
|
|
for: indexPath,
|
|
item: dialog
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Update Data
|
|
|
|
func updateDialogs(pinned: [Dialog], unpinned: [Dialog], requestsCount: Int,
|
|
typingDialogs: [String: Set<String>], isSyncing: Bool) {
|
|
self.typingDialogs = typingDialogs
|
|
self.isSyncing = isSyncing
|
|
|
|
// Check if structure changed (IDs or order)
|
|
let oldPinnedIds = self.pinnedDialogs.map(\.id)
|
|
let oldUnpinnedIds = self.unpinnedDialogs.map(\.id)
|
|
let newPinnedIds = pinned.map(\.id)
|
|
let newUnpinnedIds = unpinned.map(\.id)
|
|
let structureChanged = oldPinnedIds != newPinnedIds
|
|
|| oldUnpinnedIds != newUnpinnedIds
|
|
|| self.requestsCount != requestsCount
|
|
|
|
self.pinnedDialogs = pinned
|
|
self.unpinnedDialogs = unpinned
|
|
self.requestsCount = requestsCount
|
|
|
|
// Build lookup map
|
|
dialogMap.removeAll(keepingCapacity: true)
|
|
for d in pinned { dialogMap[d.id] = d }
|
|
for d in unpinned { dialogMap[d.id] = d }
|
|
|
|
if structureChanged {
|
|
// Structure changed — rebuild snapshot (animate inserts/deletes/moves)
|
|
var snapshot = NSDiffableDataSourceSnapshot<Section, String>()
|
|
|
|
if requestsCount > 0 {
|
|
snapshot.appendSections([.requests])
|
|
snapshot.appendItems(["__requests__"], toSection: .requests)
|
|
}
|
|
if !pinned.isEmpty {
|
|
snapshot.appendSections([.pinned])
|
|
snapshot.appendItems(newPinnedIds, toSection: .pinned)
|
|
}
|
|
snapshot.appendSections([.unpinned])
|
|
snapshot.appendItems(newUnpinnedIds, toSection: .unpinned)
|
|
|
|
dataSource.apply(snapshot, animatingDifferences: true) { [weak self] in
|
|
self?.reportPinnedHeaderFraction(force: true)
|
|
}
|
|
} else {
|
|
reportPinnedHeaderFraction()
|
|
}
|
|
|
|
// Always reconfigure ONLY visible cells (cheap — just updates content, no layout rebuild)
|
|
reconfigureVisibleCells()
|
|
|
|
// Notify host immediately so top chrome reacts in the same frame.
|
|
onPinnedStateChange?(!pinned.isEmpty)
|
|
updateOverscrollBackground()
|
|
}
|
|
|
|
/// Directly reconfigure only visible cells — no snapshot rebuild, no animation.
|
|
/// This is the cheapest way to update cell content (online, read status, badges).
|
|
private func reconfigureVisibleCells() {
|
|
for cell in collectionView.visibleCells {
|
|
guard let indexPath = collectionView.indexPath(for: cell) else { continue }
|
|
guard let itemId = dataSource.itemIdentifier(for: indexPath) else { continue }
|
|
|
|
if let chatCell = cell as? ChatListCell, let dialog = dialogMap[itemId] {
|
|
let typingUsers = typingDialogs[dialog.opponentKey]
|
|
chatCell.configure(with: dialog, isSyncing: isSyncing, typingUsers: typingUsers)
|
|
} else if let reqCell = cell as? ChatListRequestsCell {
|
|
reqCell.configure(count: requestsCount, showBottomSeparator: true)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Scroll to Top
|
|
|
|
private func setupScrollToTop() {
|
|
NotificationCenter.default.addObserver(
|
|
self, selector: #selector(handleScrollToTop),
|
|
name: .chatListScrollToTop, object: nil
|
|
)
|
|
}
|
|
|
|
@objc private func handleScrollToTop() {
|
|
guard collectionView.numberOfSections > 0,
|
|
collectionView.numberOfItems(inSection: 0) > 0 else { return }
|
|
collectionView.scrollToItem(
|
|
at: IndexPath(item: 0, section: 0),
|
|
at: .top,
|
|
animated: true
|
|
)
|
|
// Reset search bar expansion
|
|
lastReportedExpansion = 1.0
|
|
onScrollOffsetChange?(1.0)
|
|
}
|
|
|
|
// MARK: - Swipe Actions
|
|
|
|
private func sectionForIndexPath(_ indexPath: IndexPath) -> Section? {
|
|
let identifiers = dataSource.snapshot().sectionIdentifiers
|
|
guard indexPath.section < identifiers.count else { return nil }
|
|
return identifiers[indexPath.section]
|
|
}
|
|
|
|
private func trailingSwipeActions(for indexPath: IndexPath) -> UISwipeActionsConfiguration? {
|
|
guard let section = sectionForIndexPath(indexPath),
|
|
section != .requests else { return nil }
|
|
|
|
let dialog = dialogForIndexPath(indexPath)
|
|
guard let dialog else { return nil }
|
|
|
|
// Delete
|
|
let delete = UIContextualAction(style: .destructive, title: nil) { [weak self] _, _, completion in
|
|
DispatchQueue.main.async { self?.onDeleteDialog?(dialog) }
|
|
completion(true)
|
|
}
|
|
delete.image = UIImage(systemName: "trash.fill")
|
|
delete.backgroundColor = UIColor(red: 1, green: 0.23, blue: 0.19, alpha: 1)
|
|
|
|
// Mute/Unmute (skip for Saved Messages)
|
|
guard !dialog.isSavedMessages else {
|
|
return UISwipeActionsConfiguration(actions: [delete])
|
|
}
|
|
|
|
let mute = UIContextualAction(style: .normal, title: nil) { [weak self] _, _, completion in
|
|
DispatchQueue.main.async { self?.onToggleMute?(dialog) }
|
|
completion(true)
|
|
}
|
|
mute.image = UIImage(systemName: dialog.isMuted ? "bell.fill" : "bell.slash.fill")
|
|
mute.backgroundColor = dialog.isMuted
|
|
? UIColor.systemGreen
|
|
: UIColor(red: 1, green: 0.58, blue: 0, alpha: 1) // orange
|
|
|
|
return UISwipeActionsConfiguration(actions: [delete, mute])
|
|
}
|
|
|
|
private func leadingSwipeActions(for indexPath: IndexPath) -> UISwipeActionsConfiguration? {
|
|
guard let section = sectionForIndexPath(indexPath),
|
|
section != .requests else { return nil }
|
|
|
|
let dialog = dialogForIndexPath(indexPath)
|
|
guard let dialog else { return nil }
|
|
|
|
let pin = UIContextualAction(style: .normal, title: nil) { [weak self] _, _, completion in
|
|
DispatchQueue.main.async { self?.onTogglePin?(dialog) }
|
|
completion(true)
|
|
}
|
|
pin.image = UIImage(systemName: dialog.isPinned ? "pin.slash.fill" : "pin.fill")
|
|
pin.backgroundColor = UIColor(red: 1, green: 0.58, blue: 0, alpha: 1) // orange
|
|
|
|
let config = UISwipeActionsConfiguration(actions: [pin])
|
|
config.performsFirstActionWithFullSwipe = true
|
|
return config
|
|
}
|
|
|
|
private func dialogForIndexPath(_ indexPath: IndexPath) -> Dialog? {
|
|
guard let itemId = dataSource.itemIdentifier(for: indexPath) else { return nil }
|
|
return dialogMap[itemId]
|
|
}
|
|
}
|
|
|
|
// MARK: - UICollectionViewDelegate
|
|
|
|
extension ChatListCollectionController: UICollectionViewDelegate {
|
|
|
|
// MARK: - Scroll-Linked Search Bar (Telegram: 54pt collapse distance)
|
|
|
|
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
|
// Only react to user-driven scroll, not programmatic/layout changes
|
|
guard scrollView.isDragging || scrollView.isDecelerating else { return }
|
|
let offset = scrollView.contentOffset.y + view.safeAreaInsets.top + customHeaderBarHeight + searchCollapseDistance
|
|
let expansion = max(0.0, min(1.0, 1.0 - offset / searchCollapseDistance))
|
|
if abs(expansion - lastReportedExpansion) > 0.005 {
|
|
lastReportedExpansion = expansion
|
|
onScrollOffsetChange?(expansion)
|
|
}
|
|
reportPinnedHeaderFraction()
|
|
updateOverscrollBackground()
|
|
}
|
|
|
|
func scrollViewWillEndDragging(
|
|
_ scrollView: UIScrollView,
|
|
withVelocity velocity: CGPoint,
|
|
targetContentOffset: UnsafeMutablePointer<CGPoint>
|
|
) {
|
|
// Telegram snap-to-edge: if search bar is partially visible, snap to
|
|
// fully visible (>50%) or fully hidden (<50%).
|
|
guard lastReportedExpansion > 0.0 && lastReportedExpansion < 1.0 else { return }
|
|
let headerTop = view.safeAreaInsets.top + customHeaderBarHeight
|
|
if lastReportedExpansion < 0.5 {
|
|
targetContentOffset.pointee.y = -headerTop
|
|
} else {
|
|
targetContentOffset.pointee.y = -(headerTop + searchCollapseDistance)
|
|
}
|
|
}
|
|
|
|
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
|
collectionView.deselectItem(at: indexPath, animated: true)
|
|
|
|
guard let section = sectionForIndexPath(indexPath) else { return }
|
|
|
|
if section == .requests {
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.onShowRequests?()
|
|
}
|
|
return
|
|
}
|
|
|
|
guard let dialog = dialogForIndexPath(indexPath) else { return }
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.onSelectDialog?(dialog)
|
|
}
|
|
}
|
|
|
|
// MARK: - Context Menu (Long Press)
|
|
|
|
func collectionView(
|
|
_ collectionView: UICollectionView,
|
|
contextMenuConfigurationForItemAt indexPath: IndexPath,
|
|
point: CGPoint
|
|
) -> UIContextMenuConfiguration? {
|
|
guard let section = sectionForIndexPath(indexPath),
|
|
section != .requests,
|
|
let dialog = dialogForIndexPath(indexPath) else { return nil }
|
|
|
|
return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { [weak self] _ in
|
|
guard let self else { return nil }
|
|
|
|
let pinTitle = dialog.isPinned ? "Unpin" : "Pin"
|
|
let pinImage = UIImage(systemName: dialog.isPinned ? "pin.slash" : "pin")
|
|
let pinAction = UIAction(title: pinTitle, image: pinImage) { [weak self] _ in
|
|
DispatchQueue.main.async { self?.onTogglePin?(dialog) }
|
|
}
|
|
|
|
var actions: [UIAction] = [pinAction]
|
|
|
|
if !dialog.isSavedMessages {
|
|
let muteTitle = dialog.isMuted ? "Unmute" : "Mute"
|
|
let muteImage = UIImage(systemName: dialog.isMuted ? "bell" : "bell.slash")
|
|
let muteAction = UIAction(title: muteTitle, image: muteImage) { [weak self] _ in
|
|
DispatchQueue.main.async { self?.onToggleMute?(dialog) }
|
|
}
|
|
actions.append(muteAction)
|
|
}
|
|
|
|
if dialog.unreadCount > 0 {
|
|
let readAction = UIAction(
|
|
title: "Mark as Read",
|
|
image: UIImage(systemName: "checkmark.message")
|
|
) { [weak self] _ in
|
|
DispatchQueue.main.async { self?.onMarkAsRead?(dialog) }
|
|
}
|
|
actions.append(readAction)
|
|
}
|
|
|
|
let deleteAction = UIAction(
|
|
title: "Delete",
|
|
image: UIImage(systemName: "trash"),
|
|
attributes: .destructive
|
|
) { [weak self] _ in
|
|
DispatchQueue.main.async { self?.onDeleteDialog?(dialog) }
|
|
}
|
|
actions.append(deleteAction)
|
|
|
|
return UIMenu(children: actions)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - UICollectionViewDataSourcePrefetching
|
|
|
|
extension ChatListCollectionController: UICollectionViewDataSourcePrefetching {
|
|
func collectionView(_ collectionView: UICollectionView, prefetchItemsAt indexPaths: [IndexPath]) {
|
|
for indexPath in indexPaths {
|
|
guard let itemId = dataSource.itemIdentifier(for: indexPath),
|
|
let dialog = dialogMap[itemId],
|
|
!dialog.isSavedMessages else { continue }
|
|
// Warm avatar cache on background queue
|
|
let key = dialog.opponentKey
|
|
DispatchQueue.global(qos: .userInitiated).async {
|
|
_ = AvatarRepository.shared.loadAvatar(publicKey: key)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Request Chats Cell
|
|
|
|
/// Simple cell for "Request Chats" row at the top (like Telegram's Archived Chats).
|
|
final class ChatListRequestsCell: UICollectionViewCell {
|
|
|
|
private let avatarCircle = UIView()
|
|
private let iconView = UIImageView()
|
|
private let titleLabel = UILabel()
|
|
private let subtitleLabel = UILabel()
|
|
private let separatorView = UIView()
|
|
|
|
override init(frame: CGRect) {
|
|
super.init(frame: frame)
|
|
setupSubviews()
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
private func setupSubviews() {
|
|
backgroundColor = .clear
|
|
contentView.backgroundColor = .clear
|
|
|
|
avatarCircle.backgroundColor = UIColor(RosettaColors.primaryBlue)
|
|
avatarCircle.layer.cornerRadius = 30
|
|
avatarCircle.clipsToBounds = true
|
|
contentView.addSubview(avatarCircle)
|
|
|
|
iconView.image = UIImage(systemName: "tray.and.arrow.down")?.withConfiguration(
|
|
UIImage.SymbolConfiguration(pointSize: 24, weight: .medium)
|
|
)
|
|
iconView.tintColor = .white
|
|
iconView.contentMode = .center
|
|
contentView.addSubview(iconView)
|
|
|
|
titleLabel.font = .systemFont(ofSize: 16, weight: .medium)
|
|
titleLabel.text = "Request Chats"
|
|
contentView.addSubview(titleLabel)
|
|
|
|
subtitleLabel.font = .systemFont(ofSize: 15, weight: .regular)
|
|
contentView.addSubview(subtitleLabel)
|
|
|
|
separatorView.isUserInteractionEnabled = false
|
|
contentView.addSubview(separatorView)
|
|
}
|
|
|
|
override func layoutSubviews() {
|
|
super.layoutSubviews()
|
|
let h = contentView.bounds.height
|
|
let w = contentView.bounds.width
|
|
|
|
let avatarY = floor((h - 60) / 2)
|
|
avatarCircle.frame = CGRect(x: 10, y: avatarY, width: 60, height: 60)
|
|
iconView.frame = avatarCircle.frame
|
|
|
|
titleLabel.frame = CGRect(x: 80, y: 14, width: w - 96, height: 22)
|
|
subtitleLabel.frame = CGRect(x: 80, y: 36, width: w - 96, height: 20)
|
|
|
|
let sepH = 1.0 / UIScreen.main.scale
|
|
separatorView.frame = CGRect(x: 80, y: h - sepH, width: w - 80, height: sepH)
|
|
}
|
|
|
|
func configure(count: Int, showBottomSeparator: Bool) {
|
|
let isDark = traitCollection.userInterfaceStyle == .dark
|
|
titleLabel.textColor = isDark ? .white : .black
|
|
subtitleLabel.text = count == 1 ? "1 request" : "\(count) requests"
|
|
subtitleLabel.textColor = UIColor(red: 0x8E/255, green: 0x8E/255, blue: 0x93/255, alpha: 1)
|
|
separatorView.backgroundColor = isDark
|
|
? UIColor(red: 0x54/255, green: 0x54/255, blue: 0x58/255, alpha: 0.55)
|
|
: UIColor(red: 0xC8/255, green: 0xC7/255, blue: 0xCC/255, alpha: 1)
|
|
separatorView.isHidden = !showBottomSeparator
|
|
}
|
|
|
|
override func preferredLayoutAttributesFitting(
|
|
_ layoutAttributes: UICollectionViewLayoutAttributes
|
|
) -> UICollectionViewLayoutAttributes {
|
|
let attrs = layoutAttributes.copy() as! UICollectionViewLayoutAttributes
|
|
attrs.size.height = 76
|
|
return attrs
|
|
}
|
|
}
|
|
|
|
// MARK: - ChatListCell Self-Sizing Override
|
|
|
|
extension ChatListCell {
|
|
override func preferredLayoutAttributesFitting(
|
|
_ layoutAttributes: UICollectionViewLayoutAttributes
|
|
) -> UICollectionViewLayoutAttributes {
|
|
let attrs = layoutAttributes.copy() as! UICollectionViewLayoutAttributes
|
|
attrs.size.height = ChatListCell.CellLayout.itemHeight
|
|
return attrs
|
|
}
|
|
}
|