1520 lines
57 KiB
Swift
1520 lines
57 KiB
Swift
import Combine
|
|
import Observation
|
|
import SwiftUI
|
|
import UIKit
|
|
|
|
struct ChatListUIKitView: View {
|
|
@Binding var isSearchActive: Bool
|
|
@Binding var isDetailPresented: Bool
|
|
|
|
var body: some View {
|
|
ChatListUIKitContainer(
|
|
isSearchActive: $isSearchActive,
|
|
isDetailPresented: $isDetailPresented
|
|
)
|
|
}
|
|
}
|
|
|
|
private struct ChatListUIKitContainer: UIViewControllerRepresentable {
|
|
@Binding var isSearchActive: Bool
|
|
@Binding var isDetailPresented: Bool
|
|
|
|
func makeCoordinator() -> ChatListUIKitCoordinator {
|
|
ChatListUIKitCoordinator(
|
|
isSearchActive: $isSearchActive,
|
|
isDetailPresented: $isDetailPresented
|
|
)
|
|
}
|
|
|
|
func makeUIViewController(context: Context) -> UINavigationController {
|
|
let root = ChatListRootViewController()
|
|
root.onSearchActiveChanged = { [weak coordinator = context.coordinator] active in
|
|
coordinator?.setSearchActive(active)
|
|
}
|
|
root.onDetailPresentedChanged = { [weak coordinator = context.coordinator] presented in
|
|
coordinator?.setDetailPresented(presented)
|
|
}
|
|
|
|
let nav = UINavigationController(rootViewController: root)
|
|
nav.view.backgroundColor = UIColor(RosettaColors.Adaptive.background)
|
|
nav.navigationBar.tintColor = UIColor(RosettaColors.Adaptive.text)
|
|
nav.navigationBar.prefersLargeTitles = false
|
|
return nav
|
|
}
|
|
|
|
func updateUIViewController(_ navigationController: UINavigationController, context: Context) {
|
|
context.coordinator.isSearchActive = $isSearchActive
|
|
context.coordinator.isDetailPresented = $isDetailPresented
|
|
}
|
|
}
|
|
|
|
private final class ChatListUIKitCoordinator {
|
|
var isSearchActive: Binding<Bool>
|
|
var isDetailPresented: Binding<Bool>
|
|
|
|
init(isSearchActive: Binding<Bool>, isDetailPresented: Binding<Bool>) {
|
|
self.isSearchActive = isSearchActive
|
|
self.isDetailPresented = isDetailPresented
|
|
}
|
|
|
|
func setSearchActive(_ active: Bool) {
|
|
DispatchQueue.main.async {
|
|
if self.isSearchActive.wrappedValue != active {
|
|
self.isSearchActive.wrappedValue = active
|
|
}
|
|
}
|
|
}
|
|
|
|
func setDetailPresented(_ presented: Bool) {
|
|
DispatchQueue.main.async {
|
|
if self.isDetailPresented.wrappedValue != presented {
|
|
self.isDetailPresented.wrappedValue = presented
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
final class ChatListRootViewController: UIViewController, UINavigationControllerDelegate {
|
|
|
|
var onSearchActiveChanged: ((Bool) -> Void)?
|
|
var onDetailPresentedChanged: ((Bool) -> Void)?
|
|
|
|
private let viewModel = ChatListViewModel()
|
|
private let listController = ChatListCollectionController()
|
|
private let searchHeaderView = ChatListSearchHeaderView()
|
|
private let toolbarTitleView = ChatListToolbarTitleView()
|
|
private let navigationBlurView = ChatListHeaderBlurView()
|
|
private var typingDialogs: [String: Set<String>] = [:]
|
|
private var currentSearchQuery = ""
|
|
private var searchResultUsersByKey: [String: SearchUser] = [:]
|
|
private let searchTopSpacing: CGFloat = 10
|
|
private let searchBottomSpacing: CGFloat = 5
|
|
private let searchHeaderHeight: CGFloat = 44
|
|
private var searchChromeHeight: CGFloat {
|
|
searchTopSpacing + searchHeaderHeight + searchBottomSpacing
|
|
}
|
|
|
|
private var searchHeaderTopConstraint: NSLayoutConstraint?
|
|
private var searchHeaderHeightConstraint: NSLayoutConstraint?
|
|
private var navigationBlurHeightConstraint: NSLayoutConstraint?
|
|
private var lastSearchExpansion: CGFloat = 1.0
|
|
private var lastNavigationBlurProgress: CGFloat = -1.0
|
|
private var lastNavigationBlurSearchActive = false
|
|
private var lastNavigationBlurPinnedFraction: CGFloat = -1.0
|
|
private var hasPinnedDialogs = false
|
|
private var pinnedHeaderFraction: CGFloat = 0.0
|
|
|
|
private var typingDialogsCancellable: AnyCancellable?
|
|
private var searchResultsCancellable: AnyCancellable?
|
|
private var observationTask: Task<Void, Never>?
|
|
private var openChatObserver: NSObjectProtocol?
|
|
private var didBecomeActiveObserver: NSObjectProtocol?
|
|
|
|
private lazy var editButtonControl: ChatListToolbarEditButton = {
|
|
let button = ChatListToolbarEditButton()
|
|
button.addTarget(self, action: #selector(editTapped), for: .touchUpInside)
|
|
return button
|
|
}()
|
|
|
|
private lazy var rightButtonsControl: ChatListToolbarDualActionButton = {
|
|
let button = ChatListToolbarDualActionButton()
|
|
button.onAddPressed = { [weak self] in
|
|
self?.addPressed()
|
|
}
|
|
button.onComposePressed = { [weak self] in
|
|
self?.composeTapped()
|
|
}
|
|
return button
|
|
}()
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
view.backgroundColor = UIColor(RosettaColors.Adaptive.background)
|
|
|
|
setupNavigationChrome()
|
|
setupSearchHeader()
|
|
setupListController()
|
|
setupObservers()
|
|
startObservationLoop()
|
|
render()
|
|
consumePendingRouteIfFresh()
|
|
}
|
|
|
|
override func viewWillAppear(_ animated: Bool) {
|
|
super.viewWillAppear(animated)
|
|
if navigationController?.delegate !== self {
|
|
navigationController?.delegate = self
|
|
}
|
|
// Hide nav bar on chat list; pushed VCs show it via willShow delegate
|
|
navigationController?.setNavigationBarHidden(true, animated: animated)
|
|
let blurProgress = searchHeaderView.isSearchActive ? 1.0 : (1.0 - lastSearchExpansion)
|
|
updateNavigationBarBlur(progress: blurProgress)
|
|
onDetailPresentedChanged?(navigationController?.viewControllers.count ?? 1 > 1)
|
|
}
|
|
|
|
override func viewDidLayoutSubviews() {
|
|
super.viewDidLayoutSubviews()
|
|
layoutCustomHeader()
|
|
updateNavigationBlurHeight()
|
|
}
|
|
|
|
private func layoutCustomHeader() {
|
|
let statusBarHeight = view.safeAreaInsets.top
|
|
let centerY = statusBarHeight + headerBarHeight * 0.5
|
|
let sideInset: CGFloat = 16
|
|
|
|
// Edit button (left)
|
|
let editSize = editButtonControl.intrinsicContentSize
|
|
editButtonControl.frame = CGRect(
|
|
x: sideInset,
|
|
y: centerY - editSize.height * 0.5,
|
|
width: editSize.width,
|
|
height: editSize.height
|
|
)
|
|
|
|
// Right buttons (right)
|
|
let rightSize = rightButtonsControl.intrinsicContentSize
|
|
rightButtonsControl.frame = CGRect(
|
|
x: view.bounds.width - sideInset - rightSize.width,
|
|
y: centerY - rightSize.height * 0.5,
|
|
width: rightSize.width,
|
|
height: rightSize.height
|
|
)
|
|
|
|
// Title (centered)
|
|
let titleLeft = editButtonControl.frame.maxX + 8
|
|
let titleRight = rightButtonsControl.frame.minX - 8
|
|
let titleWidth = max(0, titleRight - titleLeft)
|
|
let titleSize = toolbarTitleView.intrinsicContentSize
|
|
let actualWidth = min(titleSize.width, titleWidth)
|
|
toolbarTitleView.frame = CGRect(
|
|
x: titleLeft + (titleWidth - actualWidth) * 0.5,
|
|
y: centerY - 15,
|
|
width: actualWidth,
|
|
height: 30
|
|
)
|
|
}
|
|
|
|
private func updateNavigationBlurHeight() {
|
|
let headerBottom = headerTotalHeight
|
|
let expandedSearchHeight = searchChromeHeight * lastSearchExpansion
|
|
navigationBlurHeightConstraint?.constant = max(0, headerBottom + expandedSearchHeight + 14.0)
|
|
}
|
|
|
|
override func viewWillDisappear(_ animated: Bool) {
|
|
super.viewWillDisappear(animated)
|
|
|
|
// Keep tab bar state in sync when leaving the screen while search is active.
|
|
if searchHeaderView.isSearchActive {
|
|
searchHeaderView.endSearch(animated: false, clearText: true)
|
|
}
|
|
}
|
|
|
|
deinit {
|
|
observationTask?.cancel()
|
|
if let openChatObserver {
|
|
NotificationCenter.default.removeObserver(openChatObserver)
|
|
}
|
|
if let didBecomeActiveObserver {
|
|
NotificationCenter.default.removeObserver(didBecomeActiveObserver)
|
|
}
|
|
}
|
|
|
|
/// Height of the custom header bar content (matches UINavigationBar standard)
|
|
private let headerBarHeight: CGFloat = 44
|
|
/// Computed total header height: status bar + header bar
|
|
private var headerTotalHeight: CGFloat {
|
|
view.safeAreaInsets.top + headerBarHeight
|
|
}
|
|
|
|
private func setupNavigationChrome() {
|
|
definesPresentationContext = true
|
|
|
|
// Blur background layer (below buttons)
|
|
navigationBlurView.translatesAutoresizingMaskIntoConstraints = false
|
|
navigationBlurView.isUserInteractionEnabled = false
|
|
navigationBlurView.layer.zPosition = 40
|
|
view.addSubview(navigationBlurView)
|
|
|
|
let blurHeight = navigationBlurView.heightAnchor.constraint(equalToConstant: 0)
|
|
navigationBlurHeightConstraint = blurHeight
|
|
NSLayoutConstraint.activate([
|
|
navigationBlurView.topAnchor.constraint(equalTo: view.topAnchor),
|
|
navigationBlurView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
navigationBlurView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
|
blurHeight,
|
|
])
|
|
|
|
// Header elements as direct subviews (z=55, above blur at z=40 and search at z=50)
|
|
for v: UIView in [editButtonControl, rightButtonsControl, toolbarTitleView] {
|
|
v.layer.zPosition = 55
|
|
view.addSubview(v)
|
|
}
|
|
|
|
toolbarTitleView.addTarget(self, action: #selector(handleTitleTapped), for: .touchUpInside)
|
|
updateNavigationBarBlur(progress: 0)
|
|
}
|
|
|
|
@objc private func handleTitleTapped() {
|
|
NotificationCenter.default.post(name: .chatListScrollToTop, object: nil)
|
|
}
|
|
|
|
private func setupSearchHeader() {
|
|
searchHeaderView.translatesAutoresizingMaskIntoConstraints = false
|
|
searchHeaderView.clipsToBounds = true
|
|
searchHeaderView.layer.zPosition = 50
|
|
view.addSubview(searchHeaderView)
|
|
|
|
// With nav bar hidden, safeArea.top = status bar only.
|
|
// Search bar goes below our custom 44pt header bar.
|
|
let top = searchHeaderView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: headerBarHeight + searchTopSpacing)
|
|
let height = searchHeaderView.heightAnchor.constraint(equalToConstant: searchHeaderHeight)
|
|
searchHeaderTopConstraint = top
|
|
searchHeaderHeightConstraint = height
|
|
|
|
NSLayoutConstraint.activate([
|
|
top,
|
|
searchHeaderView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 16),
|
|
searchHeaderView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -16),
|
|
height,
|
|
])
|
|
|
|
searchHeaderView.onQueryChanged = { [weak self] query in
|
|
guard let self else { return }
|
|
self.currentSearchQuery = query
|
|
self.viewModel.setSearchQuery(query)
|
|
self.renderList()
|
|
}
|
|
|
|
searchHeaderView.onActiveChanged = { [weak self] active in
|
|
guard let self else { return }
|
|
self.applySearchExpansion(1.0, animated: true)
|
|
self.updateNavigationBarBlur(progress: active ? 1.0 : (1.0 - self.lastSearchExpansion))
|
|
self.onSearchActiveChanged?(active)
|
|
}
|
|
}
|
|
|
|
private func setupListController() {
|
|
addChild(listController)
|
|
view.insertSubview(listController.view, belowSubview: searchHeaderView)
|
|
listController.view.translatesAutoresizingMaskIntoConstraints = false
|
|
NSLayoutConstraint.activate([
|
|
listController.view.topAnchor.constraint(equalTo: view.topAnchor),
|
|
listController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
listController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
|
listController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
|
])
|
|
listController.didMove(toParent: self)
|
|
listController.setSearchHeaderExpansion(lastSearchExpansion)
|
|
|
|
listController.onSelectDialog = { [weak self] dialog in
|
|
guard let self else { return }
|
|
|
|
let selectedUser = self.searchResultUsersByKey[dialog.opponentKey]
|
|
let shouldOpenUserRoute = selectedUser != nil && !self.currentSearchQuery.isEmpty
|
|
let routeToOpen: ChatRoute
|
|
if let user = selectedUser, shouldOpenUserRoute {
|
|
self.viewModel.addToRecent(user)
|
|
routeToOpen = ChatRoute(user: user)
|
|
} else {
|
|
routeToOpen = ChatRoute(dialog: dialog)
|
|
}
|
|
|
|
if self.searchHeaderView.isSearchActive {
|
|
self.searchHeaderView.endSearch(animated: false, clearText: true)
|
|
// Let keyboard dismissal complete before starting the push animation.
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in
|
|
self?.openChat(route: routeToOpen)
|
|
}
|
|
return
|
|
}
|
|
|
|
self.openChat(route: routeToOpen)
|
|
}
|
|
listController.onDeleteDialog = { [weak self] dialog in
|
|
self?.viewModel.deleteDialog(dialog)
|
|
}
|
|
listController.onTogglePin = { [weak self] dialog in
|
|
guard let self else { return }
|
|
self.viewModel.togglePin(dialog)
|
|
self.renderList()
|
|
let progress = self.searchHeaderView.isSearchActive ? 1.0 : (1.0 - self.lastSearchExpansion)
|
|
self.updateNavigationBarBlur(progress: progress, force: true)
|
|
}
|
|
listController.onToggleMute = { [weak self] dialog in
|
|
self?.viewModel.toggleMute(dialog)
|
|
}
|
|
listController.onPinnedStateChange = { [weak self] hasPinned in
|
|
guard let self else { return }
|
|
self.hasPinnedDialogs = hasPinned
|
|
if !hasPinned {
|
|
self.pinnedHeaderFraction = 0.0
|
|
}
|
|
let progress = self.searchHeaderView.isSearchActive ? 1.0 : (1.0 - self.lastSearchExpansion)
|
|
self.updateNavigationBarBlur(progress: progress, force: true)
|
|
}
|
|
listController.onPinnedHeaderFractionChange = { [weak self] fraction in
|
|
guard let self else { return }
|
|
self.pinnedHeaderFraction = self.hasPinnedDialogs ? fraction : 0.0
|
|
let progress = self.searchHeaderView.isSearchActive ? 1.0 : (1.0 - self.lastSearchExpansion)
|
|
self.updateNavigationBarBlur(progress: progress)
|
|
}
|
|
listController.onMarkAsRead = { [weak self] dialog in
|
|
self?.viewModel.markAsRead(dialog)
|
|
}
|
|
listController.onShowRequests = { [weak self] in
|
|
self?.openRequests()
|
|
}
|
|
listController.onScrollOffsetChange = { [weak self] expansion in
|
|
self?.applySearchExpansion(expansion, animated: false)
|
|
}
|
|
}
|
|
|
|
private func setupObservers() {
|
|
typingDialogsCancellable = MessageRepository.shared.$typingDialogs
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] typing in
|
|
guard let self else { return }
|
|
self.typingDialogs = typing
|
|
self.renderList()
|
|
}
|
|
|
|
searchResultsCancellable = viewModel.$serverSearchResults
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] _ in
|
|
self?.renderList()
|
|
}
|
|
|
|
openChatObserver = NotificationCenter.default.addObserver(
|
|
forName: .openChatFromNotification,
|
|
object: nil,
|
|
queue: .main
|
|
) { [weak self] notification in
|
|
guard let route = notification.object as? ChatRoute else { return }
|
|
AppDelegate.pendingChatRoute = nil
|
|
AppDelegate.pendingChatRouteTimestamp = nil
|
|
Task { @MainActor [weak self] in
|
|
self?.openChat(route: route)
|
|
}
|
|
}
|
|
|
|
didBecomeActiveObserver = NotificationCenter.default.addObserver(
|
|
forName: UIApplication.didBecomeActiveNotification,
|
|
object: nil,
|
|
queue: .main
|
|
) { [weak self] _ in
|
|
Task { @MainActor [weak self] in
|
|
self?.consumePendingRouteIfFresh()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func startObservationLoop() {
|
|
observationTask?.cancel()
|
|
observationTask = Task { @MainActor [weak self] in
|
|
self?.observeState()
|
|
}
|
|
}
|
|
|
|
private func observeState() {
|
|
withObservationTracking {
|
|
_ = DialogRepository.shared.dialogs
|
|
_ = SessionManager.shared.syncBatchInProgress
|
|
_ = ProtocolManager.shared.connectionState
|
|
_ = AvatarRepository.shared.avatarVersion
|
|
} onChange: { [weak self] in
|
|
Task { @MainActor [weak self] in
|
|
guard let self else { return }
|
|
self.render()
|
|
self.observeState()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func render() {
|
|
updateNavigationTitle()
|
|
renderList()
|
|
}
|
|
|
|
private func applySearchExpansion(_ expansion: CGFloat, animated: Bool) {
|
|
let clamped = max(0.0, min(1.0, expansion))
|
|
if searchHeaderView.isSearchActive && clamped < 0.999 {
|
|
return
|
|
}
|
|
guard abs(clamped - lastSearchExpansion) > 0.003 else { return }
|
|
lastSearchExpansion = clamped
|
|
|
|
// Structural: top stays fixed, height collapses (Telegram: 60*progress, we use 44)
|
|
searchHeaderTopConstraint?.constant = headerBarHeight + searchTopSpacing
|
|
searchHeaderHeightConstraint?.constant = searchHeaderHeight * clamped
|
|
listController.setSearchHeaderExpansion(clamped)
|
|
|
|
// Telegram animation: NO scale transform, NO whole-view alpha.
|
|
// Height reduction + clipping handles the collapse.
|
|
// Only content (text/icon) fades separately.
|
|
searchHeaderView.transform = .identity
|
|
searchHeaderView.alpha = 1.0
|
|
searchHeaderView.isUserInteractionEnabled = clamped > 0.2
|
|
|
|
// Update internal content alpha + corner radius (Telegram behavior)
|
|
searchHeaderView.updateExpansionProgress(clamped)
|
|
|
|
updateNavigationBlurHeight()
|
|
updateNavigationBarBlur(progress: 1.0 - clamped)
|
|
|
|
let updates = { self.view.layoutIfNeeded() }
|
|
if animated {
|
|
UIView.animate(
|
|
withDuration: 0.16,
|
|
delay: 0,
|
|
options: [.curveEaseInOut, .beginFromCurrentState],
|
|
animations: updates
|
|
)
|
|
} else {
|
|
updates()
|
|
}
|
|
}
|
|
|
|
private func updateNavigationBarBlur(progress: CGFloat, force: Bool = false) {
|
|
let clamped = max(0.0, min(1.0, progress))
|
|
let isSearchActive = searchHeaderView.isSearchActive
|
|
let effectivePinnedFraction = isSearchActive ? 0.0 : pinnedHeaderFraction
|
|
|
|
let hasChanged = abs(clamped - lastNavigationBlurProgress) > 0.01
|
|
|| isSearchActive != lastNavigationBlurSearchActive
|
|
|| abs(effectivePinnedFraction - lastNavigationBlurPinnedFraction) > 0.01
|
|
guard force || hasChanged else { return }
|
|
|
|
lastNavigationBlurProgress = clamped
|
|
lastNavigationBlurSearchActive = isSearchActive
|
|
lastNavigationBlurPinnedFraction = effectivePinnedFraction
|
|
|
|
navigationBlurView.setProgress(
|
|
clamped,
|
|
pinnedFraction: effectivePinnedFraction,
|
|
isSearchActive: isSearchActive
|
|
)
|
|
}
|
|
|
|
private func updateNavigationTitle() {
|
|
let state = ProtocolManager.shared.connectionState
|
|
let isSyncing = SessionManager.shared.syncBatchInProgress
|
|
|
|
let publicKey = AccountManager.shared.currentAccount?.publicKey ?? SessionManager.shared.currentPublicKey
|
|
let displayName = SessionManager.shared.displayName
|
|
let initials = RosettaColors.initials(name: displayName, publicKey: publicKey)
|
|
let avatarIndex = RosettaColors.avatarColorIndex(for: displayName, publicKey: publicKey)
|
|
let avatarImage = AvatarRepository.shared.loadAvatar(publicKey: publicKey)
|
|
|
|
if state == .authenticated && isSyncing {
|
|
toolbarTitleView.configure(
|
|
title: "Updating...",
|
|
mode: .loading,
|
|
initials: initials,
|
|
avatarIndex: avatarIndex,
|
|
avatarImage: avatarImage
|
|
)
|
|
} else if state == .authenticated {
|
|
toolbarTitleView.configure(
|
|
title: "Chats",
|
|
mode: .avatar,
|
|
initials: initials,
|
|
avatarIndex: avatarIndex,
|
|
avatarImage: avatarImage
|
|
)
|
|
} else {
|
|
toolbarTitleView.configure(
|
|
title: "Connecting...",
|
|
mode: .loading,
|
|
initials: initials,
|
|
avatarIndex: avatarIndex,
|
|
avatarImage: avatarImage
|
|
)
|
|
}
|
|
}
|
|
|
|
private func renderList() {
|
|
let dialogs = dialogsForCurrentSearchState()
|
|
let pinned = dialogs.filter(\.isPinned)
|
|
let unpinned = dialogs.filter { !$0.isPinned }
|
|
let requestsCount = currentSearchQuery.isEmpty ? viewModel.requestsCount : 0
|
|
|
|
hasPinnedDialogs = !pinned.isEmpty
|
|
if !hasPinnedDialogs {
|
|
pinnedHeaderFraction = 0.0
|
|
}
|
|
|
|
listController.updateDialogs(
|
|
pinned: pinned,
|
|
unpinned: unpinned,
|
|
requestsCount: requestsCount,
|
|
typingDialogs: typingDialogs,
|
|
isSyncing: SessionManager.shared.syncBatchInProgress
|
|
)
|
|
}
|
|
|
|
private func dialogsForCurrentSearchState() -> [Dialog] {
|
|
searchResultUsersByKey = [:]
|
|
|
|
let all = viewModel.allModeDialogs
|
|
let query = currentSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !query.isEmpty else { return all }
|
|
|
|
if !viewModel.serverSearchResults.isEmpty {
|
|
let account = SessionManager.shared.currentPublicKey
|
|
let dialogs = viewModel.serverSearchResults.map { user in
|
|
searchResultUsersByKey[user.publicKey] = user
|
|
return Dialog(
|
|
id: user.publicKey,
|
|
account: account,
|
|
opponentKey: user.publicKey,
|
|
opponentTitle: user.title,
|
|
opponentUsername: user.username,
|
|
lastMessage: "",
|
|
lastMessageTimestamp: 0,
|
|
unreadCount: 0,
|
|
isOnline: user.online == 0,
|
|
lastSeen: 0,
|
|
verified: user.verified,
|
|
iHaveSent: true,
|
|
isPinned: false,
|
|
isMuted: false,
|
|
lastMessageFromMe: false,
|
|
lastMessageDelivered: .delivered,
|
|
lastMessageRead: false
|
|
)
|
|
}
|
|
return dialogs
|
|
}
|
|
|
|
let normalized = query.lowercased()
|
|
return all.filter { dialog in
|
|
dialog.opponentTitle.lowercased().contains(normalized)
|
|
|| dialog.opponentUsername.lowercased().contains(normalized)
|
|
|| dialog.opponentKey.lowercased().contains(normalized)
|
|
}
|
|
}
|
|
|
|
private func consumePendingRouteIfFresh() {
|
|
guard let route = AppDelegate.consumeFreshPendingRoute() else { return }
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
|
|
self?.openChat(route: route)
|
|
}
|
|
}
|
|
|
|
private func openChat(route: ChatRoute) {
|
|
let detail = ChatDetailView(
|
|
route: route,
|
|
onPresentedChange: { [weak self] presented in
|
|
self?.onDetailPresentedChanged?(presented)
|
|
}
|
|
)
|
|
|
|
let hosting = UIHostingController(rootView: detail.id(route.publicKey))
|
|
hosting.view.backgroundColor = UIColor(RosettaColors.Adaptive.background)
|
|
navigationController?.pushViewController(hosting, animated: true)
|
|
}
|
|
|
|
private func openRequests() {
|
|
let vc = RequestChatsUIKitShellController(viewModel: viewModel)
|
|
vc.onOpenRoute = { [weak self] route in
|
|
self?.openChat(route: route)
|
|
}
|
|
navigationController?.pushViewController(vc, animated: true)
|
|
}
|
|
|
|
@objc private func editTapped() {
|
|
// UIKit shell parity: keep action reserved for future editing mode.
|
|
}
|
|
|
|
@objc private func addPressed() {
|
|
composeTapped()
|
|
}
|
|
|
|
@objc private func composeTapped() {
|
|
let sheet = UIAlertController(title: "New", message: nil, preferredStyle: .actionSheet)
|
|
|
|
sheet.addAction(UIAlertAction(title: "New Group", style: .default, handler: { [weak self] _ in
|
|
self?.presentGroupSetup()
|
|
}))
|
|
|
|
sheet.addAction(UIAlertAction(title: "Join Group", style: .default, handler: { [weak self] _ in
|
|
self?.presentGroupJoin()
|
|
}))
|
|
|
|
sheet.addAction(UIAlertAction(title: "Cancel", style: .cancel))
|
|
|
|
if let popover = sheet.popoverPresentationController {
|
|
popover.sourceView = rightButtonsControl
|
|
popover.sourceRect = rightButtonsControl.bounds
|
|
popover.permittedArrowDirections = .up
|
|
}
|
|
|
|
present(sheet, animated: true)
|
|
}
|
|
|
|
private func presentGroupSetup() {
|
|
let root = NavigationStack {
|
|
GroupSetupView { [weak self] route in
|
|
guard let self else { return }
|
|
self.dismiss(animated: true) {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
|
self.openChat(route: route)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let host = UIHostingController(rootView: root)
|
|
host.view.backgroundColor = UIColor(RosettaColors.Adaptive.background)
|
|
let nav = UINavigationController(rootViewController: host)
|
|
nav.modalPresentationStyle = .pageSheet
|
|
if let sheet = nav.sheetPresentationController {
|
|
sheet.detents = [.large()]
|
|
}
|
|
present(nav, animated: true)
|
|
}
|
|
|
|
private func presentGroupJoin() {
|
|
let root = NavigationStack {
|
|
GroupJoinView { [weak self] route in
|
|
guard let self else { return }
|
|
self.dismiss(animated: true) {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
|
self.openChat(route: route)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let host = UIHostingController(rootView: root)
|
|
host.view.backgroundColor = UIColor(RosettaColors.Adaptive.background)
|
|
let nav = UINavigationController(rootViewController: host)
|
|
nav.modalPresentationStyle = .pageSheet
|
|
if let sheet = nav.sheetPresentationController {
|
|
sheet.detents = [.large()]
|
|
}
|
|
present(nav, animated: true)
|
|
}
|
|
|
|
// MARK: - UINavigationControllerDelegate
|
|
|
|
func navigationController(
|
|
_ navigationController: UINavigationController,
|
|
willShow viewController: UIViewController,
|
|
animated: Bool
|
|
) {
|
|
// Show standard nav bar for pushed screens, hide on chat list
|
|
let isChatList = viewController === self
|
|
navigationController.setNavigationBarHidden(isChatList, animated: animated)
|
|
}
|
|
|
|
func navigationController(
|
|
_ navigationController: UINavigationController,
|
|
didShow viewController: UIViewController,
|
|
animated: Bool
|
|
) {
|
|
let isPresented = navigationController.viewControllers.count > 1
|
|
onDetailPresentedChanged?(isPresented)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
final class RequestChatsUIKitShellController: UIViewController {
|
|
|
|
var onOpenRoute: ((ChatRoute) -> Void)?
|
|
|
|
private let viewModel: ChatListViewModel
|
|
private let requestsController = RequestChatsController()
|
|
private var observationTask: Task<Void, Never>?
|
|
|
|
init(viewModel: ChatListViewModel) {
|
|
self.viewModel = viewModel
|
|
super.init(nibName: nil, bundle: nil)
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
view.backgroundColor = UIColor(RosettaColors.Adaptive.background)
|
|
title = "Request Chats"
|
|
|
|
addChild(requestsController)
|
|
view.addSubview(requestsController.view)
|
|
requestsController.view.translatesAutoresizingMaskIntoConstraints = false
|
|
NSLayoutConstraint.activate([
|
|
requestsController.view.topAnchor.constraint(equalTo: view.topAnchor),
|
|
requestsController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
requestsController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
|
requestsController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
|
])
|
|
requestsController.didMove(toParent: self)
|
|
|
|
requestsController.onSelectDialog = { [weak self] dialog in
|
|
self?.onOpenRoute?(ChatRoute(dialog: dialog))
|
|
}
|
|
requestsController.onDeleteDialog = { [weak self] dialog in
|
|
self?.viewModel.deleteDialog(dialog)
|
|
}
|
|
|
|
startObservationLoop()
|
|
render()
|
|
}
|
|
|
|
deinit {
|
|
observationTask?.cancel()
|
|
}
|
|
|
|
private func startObservationLoop() {
|
|
observationTask?.cancel()
|
|
observationTask = Task { @MainActor [weak self] in
|
|
self?.observeState()
|
|
}
|
|
}
|
|
|
|
private func observeState() {
|
|
withObservationTracking {
|
|
_ = DialogRepository.shared.dialogs
|
|
_ = SessionManager.shared.syncBatchInProgress
|
|
} onChange: { [weak self] in
|
|
Task { @MainActor [weak self] in
|
|
guard let self else { return }
|
|
self.render()
|
|
self.observeState()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func render() {
|
|
requestsController.updateDialogs(
|
|
viewModel.requestsModeDialogs,
|
|
isSyncing: SessionManager.shared.syncBatchInProgress
|
|
)
|
|
}
|
|
}
|
|
|
|
private final class ChatListSearchHeaderView: UIView, UITextFieldDelegate {
|
|
|
|
var onQueryChanged: ((String) -> Void)?
|
|
var onActiveChanged: ((Bool) -> Void)?
|
|
|
|
private(set) var isSearchActive = false
|
|
|
|
private let capsuleView = UIView()
|
|
private let placeholderStack = UIStackView()
|
|
private let placeholderIcon = UIImageView(image: UIImage(systemName: "magnifyingglass"))
|
|
private let placeholderLabel = UILabel()
|
|
|
|
private let activeStack = UIStackView()
|
|
private let activeIcon = UIImageView(image: UIImage(systemName: "magnifyingglass"))
|
|
private let textField = UITextField()
|
|
private let inlineClearButton = UIButton(type: .system)
|
|
private let cancelButton = UIButton(type: .system)
|
|
|
|
private var cancelWidthConstraint: NSLayoutConstraint!
|
|
private var suppressQueryCallback = false
|
|
|
|
override init(frame: CGRect) {
|
|
super.init(frame: frame)
|
|
setupUI()
|
|
applyColors()
|
|
updateVisualState(animated: false)
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
|
super.traitCollectionDidChange(previousTraitCollection)
|
|
applyColors()
|
|
}
|
|
|
|
func endSearch(animated: Bool, clearText: Bool) {
|
|
setSearchActive(false, animated: animated, clearText: clearText)
|
|
}
|
|
|
|
/// Telegram-style expansion animation:
|
|
/// - Corner radius scales with height (pill shape maintained)
|
|
/// - Text/icon alpha: invisible until 77% expanded, then ramps to 1.0
|
|
/// - Background: always visible (controlled by height clipping)
|
|
func updateExpansionProgress(_ progress: CGFloat) {
|
|
let currentHeight = searchBarHeight * progress
|
|
// Dynamic corner radius — Telegram: height * 0.5
|
|
capsuleView.layer.cornerRadius = max(0, currentHeight * 0.5)
|
|
|
|
// Telegram inner content alpha: 0 until 77%, then ramps to 1
|
|
let innerAlpha = max(0.0, min(1.0, (progress - 0.77) / 0.23))
|
|
placeholderStack.alpha = isSearchActive ? 0 : innerAlpha
|
|
placeholderIcon.alpha = innerAlpha
|
|
placeholderLabel.alpha = innerAlpha
|
|
}
|
|
|
|
private let searchBarHeight: CGFloat = 44
|
|
|
|
private func setupUI() {
|
|
translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
capsuleView.translatesAutoresizingMaskIntoConstraints = false
|
|
capsuleView.layer.cornerRadius = 22
|
|
capsuleView.layer.borderWidth = 0
|
|
capsuleView.clipsToBounds = true
|
|
addSubview(capsuleView)
|
|
|
|
placeholderStack.translatesAutoresizingMaskIntoConstraints = false
|
|
placeholderStack.axis = .horizontal
|
|
placeholderStack.alignment = .center
|
|
placeholderStack.spacing = 4
|
|
placeholderIcon.contentMode = .scaleAspectFit
|
|
placeholderIcon.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 15, weight: .medium)
|
|
placeholderLabel.text = "Search"
|
|
placeholderLabel.font = .systemFont(ofSize: 17)
|
|
placeholderStack.addArrangedSubview(placeholderIcon)
|
|
placeholderStack.addArrangedSubview(placeholderLabel)
|
|
capsuleView.addSubview(placeholderStack)
|
|
|
|
activeStack.translatesAutoresizingMaskIntoConstraints = false
|
|
activeStack.axis = .horizontal
|
|
activeStack.alignment = .center
|
|
activeStack.spacing = 2
|
|
activeIcon.contentMode = .scaleAspectFit
|
|
activeIcon.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 15, weight: .medium)
|
|
|
|
textField.translatesAutoresizingMaskIntoConstraints = false
|
|
textField.placeholder = "Search"
|
|
textField.font = .systemFont(ofSize: 17)
|
|
textField.autocapitalizationType = .none
|
|
textField.autocorrectionType = .no
|
|
textField.returnKeyType = .search
|
|
textField.clearButtonMode = .never
|
|
textField.delegate = self
|
|
textField.addTarget(self, action: #selector(handleTextChanged), for: .editingChanged)
|
|
|
|
inlineClearButton.translatesAutoresizingMaskIntoConstraints = false
|
|
inlineClearButton.setImage(UIImage(systemName: "xmark.circle.fill"), for: .normal)
|
|
inlineClearButton.addTarget(self, action: #selector(handleInlineClearTapped), for: .touchUpInside)
|
|
|
|
activeStack.addArrangedSubview(activeIcon)
|
|
activeStack.addArrangedSubview(textField)
|
|
activeStack.addArrangedSubview(inlineClearButton)
|
|
capsuleView.addSubview(activeStack)
|
|
|
|
cancelButton.translatesAutoresizingMaskIntoConstraints = false
|
|
cancelButton.setTitle("Cancel", for: .normal)
|
|
cancelButton.titleLabel?.font = .systemFont(ofSize: 17)
|
|
cancelButton.contentHorizontalAlignment = .right
|
|
cancelButton.addTarget(self, action: #selector(handleCancelTapped), for: .touchUpInside)
|
|
addSubview(cancelButton)
|
|
|
|
let tap = UITapGestureRecognizer(target: self, action: #selector(handleCapsuleTapped))
|
|
capsuleView.addGestureRecognizer(tap)
|
|
|
|
cancelWidthConstraint = cancelButton.widthAnchor.constraint(equalToConstant: 0)
|
|
|
|
NSLayoutConstraint.activate([
|
|
capsuleView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
capsuleView.topAnchor.constraint(equalTo: topAnchor),
|
|
capsuleView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
|
capsuleView.trailingAnchor.constraint(equalTo: cancelButton.leadingAnchor),
|
|
|
|
cancelButton.trailingAnchor.constraint(equalTo: trailingAnchor),
|
|
cancelButton.centerYAnchor.constraint(equalTo: centerYAnchor),
|
|
cancelWidthConstraint,
|
|
|
|
placeholderStack.centerXAnchor.constraint(equalTo: capsuleView.centerXAnchor),
|
|
placeholderStack.centerYAnchor.constraint(equalTo: capsuleView.centerYAnchor),
|
|
|
|
activeStack.leadingAnchor.constraint(equalTo: capsuleView.leadingAnchor, constant: 12),
|
|
activeStack.trailingAnchor.constraint(equalTo: capsuleView.trailingAnchor, constant: -10),
|
|
activeStack.topAnchor.constraint(equalTo: capsuleView.topAnchor),
|
|
activeStack.bottomAnchor.constraint(equalTo: capsuleView.bottomAnchor),
|
|
|
|
inlineClearButton.widthAnchor.constraint(equalToConstant: 24),
|
|
inlineClearButton.heightAnchor.constraint(equalToConstant: 24),
|
|
])
|
|
}
|
|
|
|
private func applyColors() {
|
|
// Telegram exact colors from DefaultDarkPresentationTheme / DefaultDayPresentationTheme
|
|
let isDark = traitCollection.userInterfaceStyle == .dark
|
|
// regularSearchBarColor: dark #272728, light #e9e9e9
|
|
capsuleView.backgroundColor = isDark
|
|
? UIColor(red: 0x27/255.0, green: 0x27/255.0, blue: 0x28/255.0, alpha: 1.0)
|
|
: UIColor(red: 0xe9/255.0, green: 0xe9/255.0, blue: 0xe9/255.0, alpha: 1.0)
|
|
// inputPlaceholderTextColor: dark #8f8f8f, light #8e8e93
|
|
let placeholderColor = isDark
|
|
? UIColor(red: 0x8f/255.0, green: 0x8f/255.0, blue: 0x8f/255.0, alpha: 1.0)
|
|
: UIColor(red: 0x8e/255.0, green: 0x8e/255.0, blue: 0x93/255.0, alpha: 1.0)
|
|
placeholderLabel.textColor = placeholderColor
|
|
placeholderIcon.tintColor = placeholderColor
|
|
activeIcon.tintColor = placeholderColor
|
|
// inputTextColor: dark #ffffff, light #000000
|
|
textField.textColor = isDark ? .white : .black
|
|
inlineClearButton.tintColor = placeholderColor
|
|
cancelButton.setTitleColor(UIColor(RosettaColors.primaryBlue), for: .normal)
|
|
}
|
|
|
|
private func updateVisualState(animated: Bool) {
|
|
let updates = {
|
|
self.placeholderStack.alpha = self.isSearchActive ? 0 : 1
|
|
self.activeStack.alpha = self.isSearchActive ? 1 : 0
|
|
self.cancelButton.alpha = self.isSearchActive ? 1 : 0
|
|
self.cancelWidthConstraint.constant = self.isSearchActive ? 64 : 0
|
|
self.layoutIfNeeded()
|
|
}
|
|
|
|
if animated {
|
|
UIView.animate(withDuration: 0.16, delay: 0, options: [.curveEaseInOut, .beginFromCurrentState], animations: updates)
|
|
} else {
|
|
updates()
|
|
}
|
|
}
|
|
|
|
private func setSearchActive(_ active: Bool, animated: Bool, clearText: Bool = false) {
|
|
let oldValue = isSearchActive
|
|
isSearchActive = active
|
|
|
|
if !active {
|
|
textField.resignFirstResponder()
|
|
if clearText {
|
|
setQueryText("")
|
|
}
|
|
} else {
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.textField.becomeFirstResponder()
|
|
}
|
|
}
|
|
|
|
if oldValue != active {
|
|
onActiveChanged?(active)
|
|
}
|
|
|
|
updateClearButtonVisibility()
|
|
updateVisualState(animated: animated)
|
|
}
|
|
|
|
private func setQueryText(_ text: String) {
|
|
let old = textField.text ?? ""
|
|
guard old != text else { return }
|
|
suppressQueryCallback = true
|
|
textField.text = text
|
|
suppressQueryCallback = false
|
|
updateClearButtonVisibility()
|
|
onQueryChanged?(text)
|
|
}
|
|
|
|
private func updateClearButtonVisibility() {
|
|
inlineClearButton.alpha = (textField.text?.isEmpty == false && isSearchActive) ? 1 : 0
|
|
inlineClearButton.isUserInteractionEnabled = inlineClearButton.alpha > 0
|
|
}
|
|
|
|
@objc private func handleCapsuleTapped() {
|
|
guard !isSearchActive else { return }
|
|
setSearchActive(true, animated: true)
|
|
}
|
|
|
|
@objc private func handleTextChanged() {
|
|
updateClearButtonVisibility()
|
|
guard !suppressQueryCallback else { return }
|
|
onQueryChanged?(textField.text ?? "")
|
|
}
|
|
|
|
@objc private func handleInlineClearTapped() {
|
|
setQueryText("")
|
|
}
|
|
|
|
@objc private func handleCancelTapped() {
|
|
endSearch(animated: true, clearText: true)
|
|
}
|
|
|
|
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
|
|
textField.resignFirstResponder()
|
|
return true
|
|
}
|
|
}
|
|
|
|
private final class ChatListHeaderBlurView: UIView {
|
|
|
|
// Tint overlay — shows pinned section background color via gradient mask
|
|
private let tintView = UIView()
|
|
private let tintMaskView = UIImageView()
|
|
// CABackdropLayer — captures content behind and applies subtle blur
|
|
private var backdropLayer: CALayer?
|
|
private let fadeMaskLayer = CAGradientLayer()
|
|
private var plainBackgroundColor: UIColor = .black
|
|
private var pinnedBackgroundColor: UIColor = .black
|
|
private var currentProgress: CGFloat = 0.0
|
|
private var currentPinnedFraction: CGFloat = 0.0
|
|
private var isSearchCurrentlyActive = false
|
|
|
|
override init(frame: CGRect) {
|
|
super.init(frame: frame)
|
|
isUserInteractionEnabled = false
|
|
|
|
// Backdrop blur layer — very subtle (radius 1.0), no colorMatrix
|
|
if let backdrop = BackdropLayerHelper.createBackdropLayer() {
|
|
backdrop.delegate = BackdropLayerDelegate.shared
|
|
BackdropLayerHelper.setScale(backdrop, scale: 0.5)
|
|
if let blur = CALayer.blurFilter() {
|
|
blur.setValue(1.0 as NSNumber, forKey: "inputRadius")
|
|
backdrop.filters = [blur]
|
|
}
|
|
layer.addSublayer(backdrop)
|
|
self.backdropLayer = backdrop
|
|
}
|
|
|
|
// Tint view with gradient mask (for pinned section color)
|
|
tintView.mask = tintMaskView
|
|
tintView.alpha = 0.85
|
|
addSubview(tintView)
|
|
|
|
// Gradient fade mask on the whole view
|
|
layer.mask = fadeMaskLayer
|
|
|
|
applyAdaptiveColors()
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
|
super.traitCollectionDidChange(previousTraitCollection)
|
|
guard traitCollection.userInterfaceStyle != previousTraitCollection?.userInterfaceStyle else { return }
|
|
applyAdaptiveColors()
|
|
}
|
|
|
|
override func layoutSubviews() {
|
|
super.layoutSubviews()
|
|
backdropLayer?.frame = bounds
|
|
tintView.frame = bounds
|
|
tintMaskView.frame = bounds
|
|
updateFadeMask()
|
|
updateTintMask()
|
|
}
|
|
|
|
private func applyAdaptiveColors() {
|
|
plainBackgroundColor = UIColor(RosettaColors.Adaptive.background)
|
|
pinnedBackgroundColor = UIColor(RosettaColors.Adaptive.pinnedSectionBackground)
|
|
updateEdgeEffectColor()
|
|
updateChromeOpacity()
|
|
}
|
|
|
|
private func updateEdgeEffectColor() {
|
|
let effectivePinnedFraction = isSearchCurrentlyActive ? 0.0 : currentPinnedFraction
|
|
let resolved = plainBackgroundColor.mixedWith(pinnedBackgroundColor, alpha: effectivePinnedFraction)
|
|
tintView.backgroundColor = resolved
|
|
}
|
|
|
|
private func updateChromeOpacity() {
|
|
let clamped = max(0.0, min(1.0, currentProgress))
|
|
// Backdrop blur is always present — its visibility depends on content behind.
|
|
// Tint overlay fades in with scroll progress.
|
|
tintView.alpha = 0.85 * clamped
|
|
}
|
|
|
|
private func updateTintMask() {
|
|
let height = max(1, bounds.height)
|
|
let edgeSize = min(54.0, height)
|
|
tintMaskView.image = VariableBlurEdgeView.generateEdgeGradient(baseHeight: edgeSize)
|
|
}
|
|
|
|
private func updateFadeMask() {
|
|
let height = max(1, bounds.height)
|
|
let fadeHeight = min(54.0, height)
|
|
let fadeStart = max(0.0, (height - fadeHeight) / height)
|
|
fadeMaskLayer.frame = bounds
|
|
fadeMaskLayer.startPoint = CGPoint(x: 0.5, y: 0)
|
|
fadeMaskLayer.endPoint = CGPoint(x: 0.5, y: 1)
|
|
fadeMaskLayer.colors = [UIColor.black.cgColor, UIColor.black.cgColor, UIColor.clear.cgColor]
|
|
fadeMaskLayer.locations = [0, NSNumber(value: Float(fadeStart)), 1]
|
|
}
|
|
|
|
func setProgress(_ progress: CGFloat, pinnedFraction: CGFloat, isSearchActive: Bool) {
|
|
currentProgress = max(0.0, min(1.0, progress))
|
|
currentPinnedFraction = max(0.0, min(1.0, pinnedFraction))
|
|
isSearchCurrentlyActive = isSearchActive
|
|
updateEdgeEffectColor()
|
|
updateChromeOpacity()
|
|
}
|
|
}
|
|
|
|
private final class ChatListToolbarGlassCapsuleView: UIView {
|
|
|
|
private let glassView = TelegramGlassUIView(frame: .zero)
|
|
|
|
override init(frame: CGRect) {
|
|
super.init(frame: frame)
|
|
isUserInteractionEnabled = false
|
|
clipsToBounds = false
|
|
addSubview(glassView)
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
override func layoutSubviews() {
|
|
super.layoutSubviews()
|
|
glassView.frame = bounds
|
|
glassView.fixedCornerRadius = bounds.height * 0.5
|
|
glassView.updateGlass()
|
|
}
|
|
}
|
|
|
|
private final class ChatListToolbarEditButton: UIControl {
|
|
|
|
private let backgroundView = ChatListToolbarGlassCapsuleView()
|
|
private let titleLabel = UILabel()
|
|
|
|
override init(frame: CGRect) {
|
|
super.init(frame: frame)
|
|
|
|
isAccessibilityElement = true
|
|
accessibilityLabel = "Edit"
|
|
accessibilityTraits = .button
|
|
|
|
addSubview(backgroundView)
|
|
backgroundView.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
titleLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
titleLabel.text = "Edit"
|
|
titleLabel.font = .systemFont(ofSize: 17, weight: .medium)
|
|
titleLabel.textColor = UIColor(RosettaColors.Adaptive.text)
|
|
titleLabel.textAlignment = .center
|
|
addSubview(titleLabel)
|
|
|
|
NSLayoutConstraint.activate([
|
|
backgroundView.topAnchor.constraint(equalTo: topAnchor),
|
|
backgroundView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
backgroundView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
|
backgroundView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
|
|
|
titleLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
|
|
titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
|
|
heightAnchor.constraint(equalToConstant: 44),
|
|
])
|
|
|
|
self.frame = CGRect(origin: .zero, size: intrinsicContentSize)
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
override var intrinsicContentSize: CGSize {
|
|
let textWidth = (titleLabel.text as NSString?)?.size(withAttributes: [.font: titleLabel.font!]).width ?? 0
|
|
let width = max(44.0, ceil(textWidth) + 24.0)
|
|
return CGSize(width: width, height: 44.0)
|
|
}
|
|
|
|
override var isHighlighted: Bool {
|
|
didSet {
|
|
if isHighlighted {
|
|
alpha = 0.6
|
|
} else {
|
|
UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseOut) {
|
|
self.alpha = 1.0
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private final class ChatListToolbarDualActionButton: UIView {
|
|
|
|
var onAddPressed: (() -> Void)?
|
|
var onComposePressed: (() -> Void)?
|
|
|
|
private let backgroundView = ChatListToolbarGlassCapsuleView()
|
|
private let addButton = UIButton(type: .system)
|
|
private let composeButton = UIButton(type: .system)
|
|
private let dividerView = UIView()
|
|
|
|
override init(frame: CGRect) {
|
|
super.init(frame: frame)
|
|
|
|
isAccessibilityElement = false
|
|
|
|
backgroundView.translatesAutoresizingMaskIntoConstraints = false
|
|
addSubview(backgroundView)
|
|
|
|
addButton.translatesAutoresizingMaskIntoConstraints = false
|
|
composeButton.translatesAutoresizingMaskIntoConstraints = false
|
|
dividerView.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
addButton.tintColor = UIColor(RosettaColors.Adaptive.text)
|
|
composeButton.tintColor = UIColor(RosettaColors.Adaptive.text)
|
|
addButton.accessibilityLabel = "Add"
|
|
composeButton.accessibilityLabel = "Compose"
|
|
|
|
let iconConfig = UIImage.SymbolConfiguration(pointSize: 18, weight: .medium)
|
|
let addIcon = UIImage(named: "toolbar-add-chat")?.withRenderingMode(.alwaysTemplate)
|
|
?? UIImage(systemName: "plus", withConfiguration: iconConfig)
|
|
let composeIcon = UIImage(named: "toolbar-compose")?.withRenderingMode(.alwaysTemplate)
|
|
?? UIImage(systemName: "square.and.pencil", withConfiguration: iconConfig)
|
|
addButton.setImage(addIcon, for: .normal)
|
|
composeButton.setImage(composeIcon, for: .normal)
|
|
|
|
addButton.addTarget(self, action: #selector(handleAddTapped), for: .touchUpInside)
|
|
composeButton.addTarget(self, action: #selector(handleComposeTapped), for: .touchUpInside)
|
|
addButton.addTarget(self, action: #selector(handleTouchDown(_:)), for: .touchDown)
|
|
composeButton.addTarget(self, action: #selector(handleTouchDown(_:)), for: .touchDown)
|
|
addButton.addTarget(self, action: #selector(handleTouchUp(_:)), for: [.touchUpInside, .touchCancel, .touchDragExit])
|
|
composeButton.addTarget(self, action: #selector(handleTouchUp(_:)), for: [.touchUpInside, .touchCancel, .touchDragExit])
|
|
|
|
dividerView.backgroundColor = UIColor.white.withAlphaComponent(0.16)
|
|
|
|
addSubview(addButton)
|
|
addSubview(composeButton)
|
|
addSubview(dividerView)
|
|
|
|
NSLayoutConstraint.activate([
|
|
backgroundView.topAnchor.constraint(equalTo: topAnchor),
|
|
backgroundView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
backgroundView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
|
backgroundView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
|
|
|
addButton.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
addButton.topAnchor.constraint(equalTo: topAnchor),
|
|
addButton.bottomAnchor.constraint(equalTo: bottomAnchor),
|
|
addButton.widthAnchor.constraint(equalToConstant: 44),
|
|
|
|
composeButton.trailingAnchor.constraint(equalTo: trailingAnchor),
|
|
composeButton.topAnchor.constraint(equalTo: topAnchor),
|
|
composeButton.bottomAnchor.constraint(equalTo: bottomAnchor),
|
|
composeButton.widthAnchor.constraint(equalToConstant: 44),
|
|
|
|
dividerView.centerXAnchor.constraint(equalTo: centerXAnchor),
|
|
dividerView.centerYAnchor.constraint(equalTo: centerYAnchor),
|
|
dividerView.widthAnchor.constraint(equalToConstant: 1.0 / UIScreen.main.scale),
|
|
dividerView.heightAnchor.constraint(equalToConstant: 20),
|
|
|
|
heightAnchor.constraint(equalToConstant: 44),
|
|
widthAnchor.constraint(equalToConstant: 88),
|
|
])
|
|
|
|
self.frame = CGRect(origin: .zero, size: intrinsicContentSize)
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
override var intrinsicContentSize: CGSize {
|
|
CGSize(width: 88, height: 44)
|
|
}
|
|
|
|
@objc private func handleAddTapped() {
|
|
onAddPressed?()
|
|
}
|
|
|
|
@objc private func handleComposeTapped() {
|
|
onComposePressed?()
|
|
}
|
|
|
|
@objc private func handleTouchDown(_ sender: UIButton) {
|
|
sender.alpha = 0.6
|
|
}
|
|
|
|
@objc private func handleTouchUp(_ sender: UIButton) {
|
|
UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseOut) {
|
|
sender.alpha = 1.0
|
|
}
|
|
}
|
|
}
|
|
|
|
private final class ChatListToolbarTitleView: UIControl {
|
|
|
|
enum Mode {
|
|
case loading
|
|
case avatar
|
|
}
|
|
|
|
private let stackView = UIStackView()
|
|
private let titleLabel = UILabel()
|
|
private let avatarContainer = UIView()
|
|
private let avatarImageView = UIImageView()
|
|
private let avatarInitialsLabel = UILabel()
|
|
private let spinner = ChatListToolbarArcSpinnerView()
|
|
|
|
override init(frame: CGRect) {
|
|
super.init(frame: frame)
|
|
setupUI()
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
override var intrinsicContentSize: CGSize {
|
|
let fitting = stackView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
|
|
return CGSize(width: fitting.width, height: 30)
|
|
}
|
|
|
|
func configure(title: String, mode: Mode, initials: String, avatarIndex: Int, avatarImage: UIImage?) {
|
|
titleLabel.text = title
|
|
avatarInitialsLabel.text = initials
|
|
|
|
let tintColor = RosettaColors.avatarColor(for: avatarIndex)
|
|
avatarContainer.backgroundColor = tintColor
|
|
|
|
if let avatarImage {
|
|
avatarImageView.image = avatarImage
|
|
avatarImageView.isHidden = false
|
|
avatarInitialsLabel.isHidden = true
|
|
} else {
|
|
avatarImageView.image = nil
|
|
avatarImageView.isHidden = true
|
|
avatarInitialsLabel.isHidden = false
|
|
}
|
|
|
|
switch mode {
|
|
case .loading:
|
|
spinner.isHidden = false
|
|
spinner.startAnimating()
|
|
avatarContainer.isHidden = true
|
|
case .avatar:
|
|
spinner.stopAnimating()
|
|
spinner.isHidden = true
|
|
avatarContainer.isHidden = false
|
|
}
|
|
|
|
invalidateIntrinsicContentSize()
|
|
}
|
|
|
|
private func setupUI() {
|
|
isAccessibilityElement = true
|
|
accessibilityTraits = .button
|
|
|
|
stackView.translatesAutoresizingMaskIntoConstraints = false
|
|
stackView.axis = .horizontal
|
|
stackView.alignment = .center
|
|
stackView.spacing = 6
|
|
addSubview(stackView)
|
|
|
|
spinner.translatesAutoresizingMaskIntoConstraints = false
|
|
spinner.setColor(UIColor(RosettaColors.primaryBlue))
|
|
|
|
avatarContainer.translatesAutoresizingMaskIntoConstraints = false
|
|
avatarContainer.clipsToBounds = true
|
|
avatarContainer.layer.cornerRadius = 14
|
|
|
|
avatarImageView.translatesAutoresizingMaskIntoConstraints = false
|
|
avatarImageView.contentMode = .scaleAspectFill
|
|
avatarImageView.clipsToBounds = true
|
|
|
|
avatarInitialsLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
avatarInitialsLabel.font = .systemFont(ofSize: 12, weight: .semibold)
|
|
avatarInitialsLabel.textColor = .white
|
|
avatarInitialsLabel.textAlignment = .center
|
|
|
|
avatarContainer.addSubview(avatarImageView)
|
|
avatarContainer.addSubview(avatarInitialsLabel)
|
|
|
|
titleLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
titleLabel.font = .systemFont(ofSize: 17, weight: .semibold)
|
|
titleLabel.textColor = .white
|
|
titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
|
|
|
|
stackView.addArrangedSubview(spinner)
|
|
stackView.addArrangedSubview(avatarContainer)
|
|
stackView.addArrangedSubview(titleLabel)
|
|
|
|
NSLayoutConstraint.activate([
|
|
stackView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
stackView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
|
stackView.topAnchor.constraint(equalTo: topAnchor),
|
|
stackView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
|
|
|
spinner.widthAnchor.constraint(equalToConstant: 20),
|
|
spinner.heightAnchor.constraint(equalToConstant: 20),
|
|
|
|
avatarContainer.widthAnchor.constraint(equalToConstant: 28),
|
|
avatarContainer.heightAnchor.constraint(equalToConstant: 28),
|
|
|
|
avatarImageView.leadingAnchor.constraint(equalTo: avatarContainer.leadingAnchor),
|
|
avatarImageView.trailingAnchor.constraint(equalTo: avatarContainer.trailingAnchor),
|
|
avatarImageView.topAnchor.constraint(equalTo: avatarContainer.topAnchor),
|
|
avatarImageView.bottomAnchor.constraint(equalTo: avatarContainer.bottomAnchor),
|
|
|
|
avatarInitialsLabel.leadingAnchor.constraint(equalTo: avatarContainer.leadingAnchor),
|
|
avatarInitialsLabel.trailingAnchor.constraint(equalTo: avatarContainer.trailingAnchor),
|
|
avatarInitialsLabel.topAnchor.constraint(equalTo: avatarContainer.topAnchor),
|
|
avatarInitialsLabel.bottomAnchor.constraint(equalTo: avatarContainer.bottomAnchor),
|
|
])
|
|
}
|
|
}
|
|
|
|
private final class ChatListToolbarArcSpinnerView: UIView {
|
|
|
|
private let arcLayer = CAShapeLayer()
|
|
private let animationKey = "chatlist.toolbar.arcSpinner.rotation"
|
|
|
|
override init(frame: CGRect) {
|
|
super.init(frame: frame)
|
|
isUserInteractionEnabled = false
|
|
backgroundColor = .clear
|
|
|
|
arcLayer.fillColor = UIColor.clear.cgColor
|
|
arcLayer.lineWidth = 2
|
|
arcLayer.lineCap = .round
|
|
layer.addSublayer(arcLayer)
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
deinit {
|
|
stopAnimating()
|
|
}
|
|
|
|
override func willMove(toSuperview newSuperview: UIView?) {
|
|
super.willMove(toSuperview: newSuperview)
|
|
if newSuperview == nil {
|
|
stopAnimating()
|
|
}
|
|
}
|
|
|
|
override func layoutSubviews() {
|
|
super.layoutSubviews()
|
|
arcLayer.frame = bounds
|
|
|
|
let radius = max(0, min(bounds.width, bounds.height) * 0.5 - arcLayer.lineWidth)
|
|
let center = CGPoint(x: bounds.midX, y: bounds.midY)
|
|
let start = -CGFloat.pi / 2 + (2 * CGFloat.pi * 0.05)
|
|
let end = -CGFloat.pi / 2 + (2 * CGFloat.pi * 0.78)
|
|
arcLayer.path = UIBezierPath(
|
|
arcCenter: center,
|
|
radius: radius,
|
|
startAngle: start,
|
|
endAngle: end,
|
|
clockwise: true
|
|
).cgPath
|
|
}
|
|
|
|
func setColor(_ color: UIColor) {
|
|
arcLayer.strokeColor = color.cgColor
|
|
}
|
|
|
|
func startAnimating() {
|
|
guard layer.animation(forKey: animationKey) == nil else { return }
|
|
let animation = CABasicAnimation(keyPath: "transform.rotation.z")
|
|
animation.fromValue = 0
|
|
animation.toValue = Double.pi * 2
|
|
animation.duration = 1.0
|
|
animation.repeatCount = .infinity
|
|
animation.timingFunction = CAMediaTimingFunction(name: .linear)
|
|
layer.add(animation, forKey: animationKey)
|
|
}
|
|
|
|
func stopAnimating() {
|
|
layer.removeAnimation(forKey: animationKey)
|
|
}
|
|
}
|