Фикс: сделал subtitle в списке чатов и текст in-app баннера в одну строку с truncate

This commit is contained in:
2026-04-14 18:36:13 +05:00
parent 69ac9cd270
commit 400538bf2a
40 changed files with 2482 additions and 1409 deletions

View File

@@ -1,814 +1,21 @@
import Combine
import SwiftUI
import UIKit
// MARK: - Navigation State (survives parent re-renders)
@MainActor
final class ChatListNavigationState: ObservableObject {
@Published var path: [ChatRoute] = []
}
// MARK: - ChatListView
/// The root chat list screen.
///
/// **IMPORTANT:** This view's `body` must NOT read any `@Observable` singleton
/// (`ProtocolManager`, `DialogRepository`, `AccountManager`, `SessionManager`)
/// directly. Such reads create implicit Observation tracking, causing the
/// NavigationStack to rebuild on every property change (e.g. during handshake)
/// and triggering "Update NavigationRequestObserver tried to update multiple
/// times per frame" app freeze.
///
/// All `@Observable` access is isolated in dedicated child views:
/// - `DeviceVerificationContentRouter` `ProtocolManager`
/// - `ToolbarStoriesAvatar` `AccountManager` / `SessionManager`
/// - `ChatListDialogContent` `DialogRepository` (via ViewModel)
/// Legacy compatibility wrapper.
/// Active implementation is UIKit in ChatListUIKitView.
struct ChatListView: View {
@Binding var isSearchActive: Bool
@Binding var isDetailPresented: Bool
@StateObject private var viewModel = ChatListViewModel()
@StateObject private var navigationState = ChatListNavigationState()
@State private var searchText = ""
@State private var hasPinnedChats = false
@State private var showRequestChats = false
@State private var showNewGroupSheet = false
@State private var showJoinGroupSheet = false
@State private var showNewChatActionSheet = false
@State private var searchBarExpansion: CGFloat = 1.0
@FocusState private var isSearchFocused: Bool
var body: some View {
NavigationStack(path: $navigationState.path) {
VStack(spacing: 0) {
// Custom search bar collapses on scroll (Telegram: 54pt distance)
customSearchBar
.padding(.horizontal, 16)
.padding(.top, isSearchActive ? 8 : 8 * searchBarExpansion)
.padding(.bottom, isSearchActive ? 8 : 8 * searchBarExpansion)
.frame(height: isSearchActive ? 60 : max(0, 60 * searchBarExpansion), alignment: .top)
.clipped()
.opacity(isSearchActive ? 1 : Double(searchBarExpansion))
.allowsHitTesting(isSearchActive || searchBarExpansion > 0.5)
.background(
(hasPinnedChats && !isSearchActive
? RosettaColors.Adaptive.pinnedSectionBackground
: Color.clear
).ignoresSafeArea(.all, edges: .top)
)
if isSearchActive {
ChatListSearchContent(
searchText: searchText,
viewModel: viewModel,
onSelectRecent: { searchText = $0 },
onOpenDialog: { route in
navigationState.path.append(route)
// Delay search dismissal so NavigationStack processes
// the push before the search overlay is removed.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
isSearchActive = false
isSearchFocused = false
searchText = ""
viewModel.setSearchQuery("")
}
}
)
} else {
normalContent
}
}
.background(RosettaColors.Adaptive.background.ignoresSafeArea())
.navigationBarTitleDisplayMode(.inline)
.toolbar(isSearchActive ? .hidden : .visible, for: .navigationBar)
.toolbar { toolbarContent }
.modifier(ChatListToolbarBackgroundModifier())
.onChange(of: isSearchActive) { _, _ in
searchBarExpansion = 1.0
}
.onChange(of: searchText) { _, newValue in
viewModel.setSearchQuery(newValue)
}
.navigationDestination(for: ChatRoute.self) { route in
ChatDetailView(
route: route,
onPresentedChange: { presented in
isDetailPresented = presented
}
)
// Force a fresh ChatDetailView when route changes at the same stack depth.
// This avoids stale message content when switching chats via notification/banner.
.id(route.publicKey)
}
.navigationDestination(isPresented: $showRequestChats) {
RequestChatsView(
viewModel: viewModel,
navigationState: navigationState
)
}
.onAppear {
isDetailPresented = !navigationState.path.isEmpty || showRequestChats
}
.onChange(of: navigationState.path) { _, newPath in
isDetailPresented = !newPath.isEmpty || showRequestChats
}
.onChange(of: showRequestChats) { _, showing in
isDetailPresented = !navigationState.path.isEmpty || showing
}
}
.tint(RosettaColors.figmaBlue)
.confirmationDialog("New", isPresented: $showNewChatActionSheet) {
Button("New Group") { showNewGroupSheet = true }
Button("Join Group") { showJoinGroupSheet = true }
Button("Cancel", role: .cancel) {}
}
.sheet(isPresented: $showNewGroupSheet) {
NavigationStack {
GroupSetupView { route in
showNewGroupSheet = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
navigationState.path = [route]
}
}
}
.presentationDetents([.large])
}
.sheet(isPresented: $showJoinGroupSheet) {
NavigationStack {
GroupJoinView { route in
showJoinGroupSheet = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
navigationState.path = [route]
}
}
}
.presentationDetents([.large])
}
.onReceive(NotificationCenter.default.publisher(for: .openChatFromNotification)) { notification in
guard let route = notification.object as? ChatRoute else { return }
AppDelegate.pendingChatRoute = nil
AppDelegate.pendingChatRouteTimestamp = nil
// Already showing this chat.
if !showRequestChats, navigationState.path.last?.publicKey == route.publicKey {
return
}
// If user is in a chat already, push target chat immediately on top.
// This avoids the list flash while still creating a fresh destination.
if !navigationState.path.isEmpty {
navigationState.path.append(route)
return
}
// If Requests screen is open, close it first, then open chat.
if showRequestChats {
showRequestChats = false
DispatchQueue.main.async {
navigationState.path = [route]
}
return
}
// Root chat-list state: open target chat directly.
navigationState.path = [route]
}
.onAppear {
// Cold start fallback: ChatListView didn't exist when notification was posted.
// Expiry guard (3s) prevents stale routes from firing on tab switches
// critical for iOS < 26 pager (ZStack opacity 01 re-fires .onAppear).
consumePendingRouteIfFresh()
}
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
// Backgroundforeground fallback: covers edge cases where .onReceive
// subscription hasn't re-activated after backgroundforeground transition.
// Harmless if .onReceive already consumed the route (statics are nil).
consumePendingRouteIfFresh()
}
}
// MARK: - Cancel Search
private func cancelSearch() {
withAnimation(.easeInOut(duration: 0.3)) {
isSearchActive = false
}
isSearchFocused = false
searchText = ""
viewModel.setSearchQuery("")
}
/// Consume pending notification route only if it was set within the last 3 seconds.
/// Prevents stale routes (from failed .onReceive) from being consumed on tab switches.
private func consumePendingRouteIfFresh() {
guard let route = AppDelegate.consumeFreshPendingRoute() else { return }
Task { @MainActor in
try? await Task.sleep(nanoseconds: 100_000_000) // 100ms for NavigationStack settle
navigationState.path = [route]
}
}
}
// MARK: - Custom Search Bar
private extension ChatListView {
var customSearchBar: some View {
HStack(spacing: 10) {
// Search bar capsule
ZStack {
// Centered placeholder: magnifier + "Search"
if searchText.isEmpty && !isSearchActive {
HStack(spacing: 6) {
Image(systemName: "magnifyingglass")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(Color.gray)
Text("Search")
.font(.system(size: 17))
.foregroundStyle(Color.gray)
}
.allowsHitTesting(false)
}
// Active: left-aligned magnifier + TextField
if isSearchActive {
HStack(spacing: 6) {
Image(systemName: "magnifyingglass")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(Color.gray)
TextField("Search", text: $searchText)
.font(.system(size: 17))
.foregroundStyle(RosettaColors.Adaptive.text)
.focused($isSearchFocused)
.submitLabel(.search)
if !searchText.isEmpty {
Button {
searchText = ""
} label: {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 15))
.foregroundStyle(Color.gray)
}
}
}
.padding(.horizontal, 12)
}
}
.frame(height: 44)
.frame(maxWidth: .infinity)
.contentShape(Rectangle())
.onTapGesture {
if !isSearchActive {
withAnimation(.easeInOut(duration: 0.14)) {
isSearchActive = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
isSearchFocused = true
}
}
}
.background {
if isSearchActive {
RoundedRectangle(cornerRadius: 22, style: .continuous)
.fill(RosettaColors.Adaptive.searchBarFill)
.overlay {
RoundedRectangle(cornerRadius: 22, style: .continuous)
.strokeBorder(RosettaColors.Adaptive.searchBarBorder, lineWidth: 0.5)
}
} else {
RoundedRectangle(cornerRadius: 22, style: .continuous)
.fill(RosettaColors.Adaptive.searchBarFill)
}
}
.onChange(of: isSearchFocused) { _, focused in
if focused && !isSearchActive {
withAnimation(.easeInOut(duration: 0.14)) {
isSearchActive = true
}
}
}
// Circular X button (visible only when search is active)
if isSearchActive {
Button {
cancelSearch()
} label: {
Image("toolbar-xmark")
.renderingMode(.template)
.resizable()
.scaledToFit()
.frame(width: 19, height: 19)
.foregroundStyle(RosettaColors.Adaptive.text)
.frame(width: 36, height: 36)
.padding(3)
}
.buttonStyle(.plain)
.background {
Circle()
.fill(RosettaColors.Adaptive.searchBarFill)
.overlay {
Circle()
.strokeBorder(RosettaColors.Adaptive.searchBarBorder, lineWidth: 0.5)
}
}
.transition(.opacity.combined(with: .scale(scale: 0.5)))
}
}
}
}
// MARK: - Normal Content
private extension ChatListView {
@ViewBuilder
var normalContent: some View {
// Observation-isolated router reads ProtocolManager in its own scope.
// Shows full-screen DeviceConfirmView when awaiting approval,
// or normal chat list with optional device approval banner otherwise.
DeviceVerificationContentRouter(
viewModel: viewModel,
navigationState: navigationState,
onShowRequests: { showRequestChats = true },
onPinnedStateChange: { pinned in
if hasPinnedChats != pinned {
withAnimation(.easeInOut(duration: 0.25)) {
hasPinnedChats = pinned
}
}
},
onScrollOffsetChange: { expansion in
searchBarExpansion = expansion
}
ChatListUIKitView(
isSearchActive: $isSearchActive,
isDetailPresented: $isDetailPresented
)
}
}
// MARK: - Toolbar
private extension ChatListView {
@ToolbarContentBuilder
var toolbarContent: some ToolbarContent {
if !isSearchActive {
if #available(iOS 26, *) {
// iOS 26+ original compact toolbar (no capsules, system icons)
ToolbarItem(placement: .navigationBarLeading) {
Button { } label: {
Text("Edit")
.font(.system(size: 17, weight: .medium))
.foregroundStyle(RosettaColors.Adaptive.text)
}
}
ToolbarItem(placement: .principal) {
HStack(spacing: 4) {
ToolbarStoriesAvatar()
ToolbarTitleView()
}
}
ToolbarItemGroup(placement: .navigationBarTrailing) {
HStack(spacing: 8) {
Button { } label: {
Image(systemName: "camera")
.font(.system(size: 16, weight: .regular))
.foregroundStyle(RosettaColors.Adaptive.text)
}
.accessibilityLabel("Camera")
Button { showNewChatActionSheet = true } label: {
Image(systemName: "square.and.pencil")
.font(.system(size: 17, weight: .regular))
.foregroundStyle(RosettaColors.Adaptive.text)
}
.padding(.bottom, 2)
.accessibilityLabel("New chat")
}
}
} else {
// iOS < 26 capsule-styled toolbar with custom icons
ToolbarItem(placement: .navigationBarLeading) {
Button { } label: {
Text("Edit")
.font(.system(size: 17, weight: .medium))
.foregroundStyle(RosettaColors.Adaptive.text)
.frame(height: 40)
.padding(.horizontal, 10)
}
.buttonStyle(.plain)
.glassCapsule()
}
ToolbarItem(placement: .principal) {
HStack(spacing: 4) {
ToolbarStoriesAvatar()
ToolbarTitleView()
}
}
ToolbarItem(placement: .navigationBarTrailing) {
HStack(spacing: 0) {
Button { } label: {
Image("toolbar-add-chat")
.renderingMode(.template)
.resizable()
.scaledToFit()
.frame(width: 22, height: 22)
.frame(width: 40, height: 40)
}
.buttonStyle(.plain)
.accessibilityLabel("Add chat")
Button { showNewChatActionSheet = true } label: {
Image("toolbar-compose")
.renderingMode(.template)
.resizable()
.scaledToFit()
.frame(width: 20, height: 20)
.frame(width: 40, height: 40)
}
.buttonStyle(.plain)
.accessibilityLabel("New chat")
}
.foregroundStyle(RosettaColors.Adaptive.text)
.glassCapsule()
}
}
}
}
}
// MARK: - Toolbar Background Modifier
struct ChatListToolbarBackgroundModifier: ViewModifier {
func body(content: Content) -> some View {
if #available(iOS 26, *) {
content
.toolbarBackground(.visible, for: .navigationBar)
.applyGlassNavBar()
} else {
content
.toolbarBackground(.hidden, for: .navigationBar)
}
}
}
// MARK: - Toolbar Title (observation-isolated)
/// Reads `ProtocolManager.shared.connectionState` and `SessionManager.shared.syncBatchInProgress`
/// in its own observation scope. State changes are absorbed here,
/// not cascaded to the parent ChatListView / NavigationStack.
private struct ToolbarTitleView: View {
var body: some View {
let state = ProtocolManager.shared.connectionState
let isSyncing = SessionManager.shared.syncBatchInProgress
if state == .authenticated && isSyncing {
ToolbarStatusLabel(title: "Updating...")
} else if state == .authenticated {
Text("Chats")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.text)
.contentTransition(.numericText())
.animation(.easeInOut(duration: 0.25), value: state)
.onTapGesture {
NotificationCenter.default.post(name: .chatListScrollToTop, object: nil)
}
} else {
ToolbarStatusLabel(title: "Connecting...")
}
}
}
/// Status text label without spinner (spinner is in ToolbarStoriesAvatar).
private struct ToolbarStatusLabel: View {
let title: String
var body: some View {
Text(title)
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.text)
}
}
// MARK: - Toolbar Stories Avatar (observation-isolated)
/// Reads `AccountManager`, `SessionManager`, and `ProtocolManager` in its own observation scope.
/// Shows a spinning arc loader during connecting/syncing, then crossfades to avatar.
private struct ToolbarStoriesAvatar: View {
@State private var isSpinning = false
var body: some View {
let pk = AccountManager.shared.currentAccount?.publicKey ?? ""
let state = ProtocolManager.shared.connectionState
let isSyncing = SessionManager.shared.syncBatchInProgress
let isLoading = state != .authenticated || isSyncing
let initials = RosettaColors.initials(
name: SessionManager.shared.displayName, publicKey: pk
)
let colorIdx = RosettaColors.avatarColorIndex(for: SessionManager.shared.displayName, publicKey: pk)
let _ = AvatarRepository.shared.avatarVersion
let avatar = AvatarRepository.shared.loadAvatar(publicKey: pk)
ZStack {
// Avatar visible when loaded
AvatarView(initials: initials, colorIndex: colorIdx, size: 28, image: avatar)
.opacity(isLoading ? 0 : 1)
// Spinning arc loader visible during connecting/syncing
Circle()
.trim(from: 0.05, to: 0.78)
.stroke(
RosettaColors.figmaBlue,
style: StrokeStyle(lineWidth: 2, lineCap: .round)
)
.frame(width: 20, height: 20)
.rotationEffect(.degrees(isSpinning ? 360 : 0))
.opacity(isLoading ? 1 : 0)
}
.animation(.easeInOut(duration: 0.3), value: isLoading)
.onAppear { isSpinning = true }
.animation(.linear(duration: 1).repeatForever(autoreverses: false), value: isSpinning)
}
}
// MARK: - Sync-Aware Empty State (observation-isolated)
/// Shows "Syncing..." indicator when sync is in progress, otherwise shows empty state.
/// Reads `SessionManager.syncBatchInProgress` in its own observation scope.
private struct SyncAwareEmptyState: View {
var body: some View {
let isSyncing = SessionManager.shared.syncBatchInProgress
if isSyncing {
VStack(spacing: 16) {
Spacer().frame(height: 120)
ProgressView()
.tint(RosettaColors.Adaptive.textSecondary)
Text("Syncing conversations…")
.font(.system(size: 15))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
Spacer()
}
.frame(maxWidth: .infinity)
} else {
ChatEmptyStateView(searchText: "")
}
}
}
// MARK: - Request Chats Row (Telegram Archive style)
/// Shown at the top of the chat list when there are incoming message requests.
/// Matches ChatRowView sizing: height 78, pl-10, pr-16, avatar 62px.
private struct RequestChatsRow: View {
let count: Int
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack(spacing: 0) {
// Avatar: solid blue circle with white icon (62px)
ZStack {
Circle()
.fill(RosettaColors.primaryBlue)
.frame(width: 62, height: 62)
Image(systemName: "tray.and.arrow.down")
.font(.system(size: 24, weight: .medium))
.foregroundStyle(.white)
}
.padding(.trailing, 10)
// Content section matches ChatRowView.contentSection layout
VStack(alignment: .leading, spacing: 0) {
// Title row
Text("Request Chats")
.font(.system(size: 17, weight: .medium))
.tracking(-0.43)
.foregroundStyle(RosettaColors.Adaptive.text)
.lineLimit(1)
// Subtitle row (count)
Text(count == 1 ? "1 request" : "\(count) requests")
.font(.system(size: 15))
.tracking(-0.23)
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
.frame(height: 63, alignment: .top)
.padding(.top, 8)
}
.padding(.leading, 10)
.padding(.trailing, 16)
.frame(height: 78)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
// MARK: - Device Verification Content Router (observation-isolated)
/// Reads `ProtocolManager` in its own observation scope.
/// During handshake, `connectionState` changes 4+ times rapidly this view
/// absorbs those re-renders instead of cascading them to the NavigationStack.
///
/// Device confirmation (THIS device waiting) is handled by full-screen overlay
/// in MainTabView (DeviceConfirmOverlay). This router only handles the
/// approval banner (ANOTHER device requesting access on primary device).
private struct DeviceVerificationContentRouter: View {
@ObservedObject var viewModel: ChatListViewModel
@ObservedObject var navigationState: ChatListNavigationState
var onShowRequests: () -> Void = {}
var onPinnedStateChange: (Bool) -> Void = { _ in }
var onScrollOffsetChange: (CGFloat) -> Void = { _ in }
var body: some View {
let proto = ProtocolManager.shared
VStack(spacing: 0) {
// Banner for approving ANOTHER device (primary device side)
if let pendingDevice = proto.pendingDeviceVerification {
DeviceApprovalBanner(
device: pendingDevice,
onAccept: { proto.acceptDevice(pendingDevice.deviceId) },
onDecline: { proto.declineDevice(pendingDevice.deviceId) }
)
}
ChatListDialogContent(
viewModel: viewModel,
navigationState: navigationState,
onShowRequests: onShowRequests,
onPinnedStateChange: onPinnedStateChange,
onScrollOffsetChange: onScrollOffsetChange
)
}
}
}
// MARK: - Dialog Content (observation-isolated)
/// Reads `DialogRepository` (via ViewModel) in its own observation scope.
/// Changes to dialogs only re-render this list, not the NavigationStack.
private struct ChatListDialogContent: View {
@ObservedObject var viewModel: ChatListViewModel
@ObservedObject var navigationState: ChatListNavigationState
var onShowRequests: () -> Void = {}
var onPinnedStateChange: (Bool) -> Void = { _ in }
var onScrollOffsetChange: (CGFloat) -> Void = { _ in }
/// Desktop parity: track typing dialogs from MessageRepository (@Published).
@State private var typingDialogs: [String: Set<String>] = [:]
var body: some View {
#if DEBUG
let _ = PerformanceLogger.shared.track("chatList.bodyEval")
#endif
// CRITICAL: Read DialogRepository.dialogs directly to establish @Observable tracking.
// Without this, ChatListDialogContent only observes viewModel (ObservableObject)
// which never publishes objectWillChange for dialog mutations.
// The read forces SwiftUI to re-evaluate body when dialogs dict changes.
let _ = DialogRepository.shared.dialogs.count
// Use pre-partitioned arrays from ViewModel (single-pass O(n) instead of 3× filter).
let pinned = viewModel.allModePinned
let unpinned = viewModel.allModeUnpinned
let requestsCount = viewModel.requestsCount
Group {
if pinned.isEmpty && unpinned.isEmpty && !viewModel.isLoading {
SyncAwareEmptyState()
} else {
dialogList(
pinned: pinned,
unpinned: unpinned,
requestsCount: requestsCount
)
}
}
.onReceive(MessageRepository.shared.$typingDialogs) { typingDialogs = $0 }
.onAppear {
onPinnedStateChange(!pinned.isEmpty)
}
}
// MARK: - Dialog List (UIKit UICollectionView)
private func dialogList(pinned: [Dialog], unpinned: [Dialog], requestsCount: Int) -> some View {
Group {
if viewModel.isLoading {
// Shimmer skeleton during initial load (SwiftUI simple, not perf-critical)
List {
ForEach(0..<8, id: \.self) { _ in
ChatRowShimmerView()
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
} else {
// UIKit UICollectionView Telegram-level scroll performance
let isSyncing = SessionManager.shared.syncBatchInProgress
ChatListCollectionView(
pinnedDialogs: pinned,
unpinnedDialogs: unpinned,
requestsCount: requestsCount,
typingDialogs: typingDialogs,
isSyncing: isSyncing,
isLoading: viewModel.isLoading,
onSelectDialog: { dialog in
navigationState.path.append(ChatRoute(dialog: dialog))
},
onDeleteDialog: { dialog in
viewModel.deleteDialog(dialog)
},
onTogglePin: { dialog in
viewModel.togglePin(dialog)
},
onToggleMute: { dialog in
viewModel.toggleMute(dialog)
},
onPinnedStateChange: onPinnedStateChange,
onShowRequests: onShowRequests,
onScrollOffsetChange: onScrollOffsetChange,
onMarkAsRead: { dialog in
viewModel.markAsRead(dialog)
}
)
}
}
}
}
// MARK: - Device Approval Banner
/// Desktop parity: clean banner with "New login from {device} ({os})" and Accept/Decline.
/// Desktop: DeviceVerify.tsx height 65px, centered text (dimmed), two transparent buttons.
private struct DeviceApprovalBanner: View {
let device: DeviceEntry
let onAccept: () -> Void
let onDecline: () -> Void
@State private var showAcceptConfirmation = false
var body: some View {
VStack(spacing: 8) {
Text("New login from \(device.deviceName) (\(device.deviceOs))")
.font(.system(size: 13, weight: .regular))
.foregroundStyle(.white.opacity(0.45))
.multilineTextAlignment(.center)
HStack(spacing: 24) {
Button("Accept") {
showAcceptConfirmation = true
}
.font(.system(size: 14, weight: .medium))
.foregroundStyle(RosettaColors.primaryBlue)
Button("Decline") {
onDecline()
}
.font(.system(size: 14, weight: .medium))
.foregroundStyle(RosettaColors.error.opacity(0.8))
}
}
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.alert("Accept new device", isPresented: $showAcceptConfirmation) {
Button("Accept") { onAccept() }
Button("Cancel", role: .cancel) {}
} message: {
Text("Are you sure you want to accept this device? This will allow it to access your account.")
}
}
}
// MARK: - iOS 26+ Classic Swipe Actions
/// iOS 26: disable Liquid Glass on the List so swipe action buttons use
/// solid colors (same as iOS < 26). Uses UIAppearance override.
private struct ClassicSwipeActionsModifier: ViewModifier {
func body(content: Content) -> some View {
content.onAppear {
if #available(iOS 26, *) {
// Disable glass on UITableView-backed List swipe actions.
let appearance = UITableView.appearance()
appearance.backgroundColor = .clear
}
}
}
}
#Preview("Chat List") {
ChatListView(isSearchActive: .constant(false), isDetailPresented: .constant(false))
.preferredColorScheme(.dark)
}
#Preview("Search Active") {
ChatListView(isSearchActive: .constant(true), isDetailPresented: .constant(false))
.preferredColorScheme(.dark)
}

View File

@@ -79,23 +79,13 @@ private struct DeviceConfirmExitButtonStyle: ButtonStyle {
private let fillColor = RosettaColors.error
func makeBody(configuration: Configuration) -> some View {
Group {
if #available(iOS 26, *) {
configuration.label
.background {
Capsule().fill(fillColor.opacity(configuration.isPressed ? 0.6 : 0.85))
}
.glassEffect(.regular, in: Capsule())
} else {
configuration.label
.background {
Capsule()
.fill(fillColor.opacity(configuration.isPressed ? 0.6 : 0.85))
}
.clipShape(Capsule())
configuration.label
.background {
Capsule()
.fill(fillColor.opacity(configuration.isPressed ? 0.6 : 0.85))
}
}
.scaleEffect(configuration.isPressed ? 0.97 : 1.0)
.animation(.easeOut(duration: 0.15), value: configuration.isPressed)
.clipShape(Capsule())
.scaleEffect(configuration.isPressed ? 0.97 : 1.0)
.animation(.easeOut(duration: 0.15), value: configuration.isPressed)
}
}

View File

@@ -1,98 +1,6 @@
import Lottie
import SwiftUI
import UIKit
// MARK: - RequestChatsView (SwiftUI shell toolbar + navigation only)
/// Screen showing incoming message requests opened from the "Request Chats"
/// row at the top of the main chat list (Telegram Archive style).
/// List content rendered by UIKit RequestChatsController for performance parity.
struct RequestChatsView: View {
@ObservedObject var viewModel: ChatListViewModel
@ObservedObject var navigationState: ChatListNavigationState
@Environment(\.dismiss) private var dismiss
var body: some View {
Group {
if viewModel.requestsModeDialogs.isEmpty {
RequestsEmptyStateView()
} else {
let isSyncing = SessionManager.shared.syncBatchInProgress
RequestChatsCollectionView(
dialogs: viewModel.requestsModeDialogs,
isSyncing: isSyncing,
onSelectDialog: { dialog in
navigationState.path.append(ChatRoute(dialog: dialog))
},
onDeleteDialog: { dialog in
viewModel.deleteDialog(dialog)
}
)
}
}
.background(RosettaColors.Adaptive.background.ignoresSafeArea())
.navigationBarTitleDisplayMode(.inline)
.navigationBarBackButtonHidden(true)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button { dismiss() } label: {
backCapsuleLabel
}
.buttonStyle(.plain)
}
ToolbarItem(placement: .principal) {
Text("Request Chats")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.text)
}
}
.modifier(ChatListToolbarBackgroundModifier())
.enableSwipeBack()
}
// MARK: - Capsule Back Button (matches ChatDetailView)
private var backCapsuleLabel: some View {
TelegramVectorIcon(
pathData: TelegramIconPath.backChevron,
viewBox: CGSize(width: 11, height: 20),
color: .white
)
.frame(width: 11, height: 20)
.allowsHitTesting(false)
.frame(width: 36, height: 36)
.frame(height: 44)
.padding(.horizontal, 4)
.background {
TelegramGlassCapsule()
}
}
}
// MARK: - RequestChatsCollectionView (UIViewControllerRepresentable bridge)
private struct RequestChatsCollectionView: UIViewControllerRepresentable {
let dialogs: [Dialog]
let isSyncing: Bool
var onSelectDialog: ((Dialog) -> Void)?
var onDeleteDialog: ((Dialog) -> Void)?
func makeUIViewController(context: Context) -> RequestChatsController {
let controller = RequestChatsController()
controller.onSelectDialog = onSelectDialog
controller.onDeleteDialog = onDeleteDialog
controller.updateDialogs(dialogs, isSyncing: isSyncing)
return controller
}
func updateUIViewController(_ controller: RequestChatsController, context: Context) {
controller.onSelectDialog = onSelectDialog
controller.onDeleteDialog = onDeleteDialog
controller.updateDialogs(dialogs, isSyncing: isSyncing)
}
}
// MARK: - RequestChatsController (UIKit)
/// Pure UIKit UICollectionView controller for request chats list.
@@ -114,12 +22,22 @@ final class RequestChatsController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .clear
view.backgroundColor = UIColor(RosettaColors.Adaptive.background)
setupCollectionView()
setupCellRegistration()
setupDataSource()
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
applyBottomInsets()
}
override func viewSafeAreaInsetsDidChange() {
super.viewSafeAreaInsetsDidChange()
applyBottomInsets()
}
// MARK: - Collection View
private func setupCollectionView() {
@@ -134,14 +52,14 @@ final class RequestChatsController: UIViewController {
collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.translatesAutoresizingMaskIntoConstraints = false
collectionView.backgroundColor = .clear
collectionView.backgroundColor = UIColor(RosettaColors.Adaptive.background)
collectionView.delegate = self
collectionView.showsHorizontalScrollIndicator = false
collectionView.showsVerticalScrollIndicator = false
collectionView.alwaysBounceHorizontal = false
collectionView.alwaysBounceVertical = true
collectionView.contentInset.bottom = 0
collectionView.verticalScrollIndicatorInsets.bottom = 0
collectionView.contentInsetAdjustmentBehavior = .never
applyBottomInsets()
view.addSubview(collectionView)
NSLayoutConstraint.activate([
@@ -152,6 +70,13 @@ final class RequestChatsController: UIViewController {
])
}
private func applyBottomInsets() {
guard collectionView != nil else { return }
let inset = view.safeAreaInsets.bottom
collectionView.contentInset.bottom = inset
collectionView.verticalScrollIndicatorInsets.bottom = inset
}
private func setupCellRegistration() {
cellRegistration = UICollectionView.CellRegistration<ChatListCell, Dialog> {
[weak self] cell, indexPath, dialog in
@@ -237,33 +162,3 @@ extension RequestChatsController: UICollectionViewDelegate {
}
}
}
// MARK: - Requests Empty State
/// Shown when there are no incoming requests.
/// Design: folder Lottie + title + subtitle.
private struct RequestsEmptyStateView: View {
var body: some View {
VStack(spacing: 0) {
LottieView(animationName: "folder_empty", loopMode: .playOnce, animationSpeed: 1.0)
.frame(width: 150, height: 150)
Spacer().frame(height: 24)
Text("No Requests")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.multilineTextAlignment(.center)
Spacer().frame(height: 8)
Text("New message requests will appear here")
.font(.system(size: 15))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.offset(y: -40)
}
}

View File

@@ -74,6 +74,7 @@ final class ChatListCell: UICollectionViewCell {
private var wasBadgeVisible = false
private var wasMentionBadgeVisible = false
private var isSystemChat = false
private var isSeparatorFullWidth = false
// MARK: - Init
@@ -152,7 +153,7 @@ final class ChatListCell: UICollectionViewCell {
// Message
messageLabel.font = .systemFont(ofSize: 15, weight: .regular)
messageLabel.numberOfLines = 2
messageLabel.numberOfLines = 1
messageLabel.lineBreakMode = .byTruncatingTail
contentView.addSubview(messageLabel)
@@ -193,11 +194,12 @@ final class ChatListCell: UICollectionViewCell {
// Pin icon
pinnedIconView.contentMode = .scaleAspectFit
pinnedIconView.image = UIImage(systemName: "pin.fill")?.withConfiguration(
let fallbackPinImage = UIImage(systemName: "pin.fill")?.withConfiguration(
UIImage.SymbolConfiguration(pointSize: 13, weight: .regular)
)
pinnedIconView.image = (UIImage(named: "PeerPinnedIcon") ?? fallbackPinImage)?
.withRenderingMode(.alwaysTemplate)
pinnedIconView.isHidden = true
pinnedIconView.transform = CGAffineTransform(rotationAngle: .pi / 4)
contentView.addSubview(pinnedIconView)
// Separator
@@ -326,11 +328,13 @@ final class ChatListCell: UICollectionViewCell {
}
if !pinnedIconView.isHidden {
let pinS: CGFloat = 16
let pinSize = pinnedIconView.image?.size ?? CGSize(width: 20, height: 20)
let contentRectMaxY = h - 13.0
pinnedIconView.frame = CGRect(
x: badgeRightEdge - pinS,
y: badgeY + floor((CellLayout.badgeDiameter - pinS) / 2),
width: pinS, height: pinS
x: badgeRightEdge - pinSize.width,
y: contentRectMaxY - pinSize.height - 2.0,
width: pinSize.width,
height: pinSize.height
)
badgeRightEdge = pinnedIconView.frame.minX - CellLayout.badgeSpacing
}
@@ -376,10 +380,11 @@ final class ChatListCell: UICollectionViewCell {
// Separator
let separatorHeight = 1.0 / scale
let separatorX = isSeparatorFullWidth ? 0 : CellLayout.separatorInset
separatorView.frame = CGRect(
x: CellLayout.separatorInset,
x: separatorX,
y: h - separatorHeight,
width: w - CellLayout.separatorInset,
width: w - separatorX,
height: separatorHeight
)
}
@@ -456,7 +461,9 @@ final class ChatListCell: UICollectionViewCell {
// Pin
pinnedIconView.isHidden = !(dialog.isPinned && dialog.unreadCount == 0)
pinnedIconView.tintColor = secondaryColor
pinnedIconView.tintColor = isDark
? UIColor(red: 0x76/255, green: 0x76/255, blue: 0x77/255, alpha: 1)
: UIColor(red: 0xB6/255, green: 0xB6/255, blue: 0xBB/255, alpha: 1)
setNeedsLayout()
}
@@ -485,12 +492,19 @@ final class ChatListCell: UICollectionViewCell {
avatarImageView.isHidden = false
avatarBackgroundView.isHidden = true
} else if dialog.isGroup {
avatarBackgroundView.backgroundColor = UIColor(colorPair.tint)
groupIconView.isHidden = false
groupIconView.image = UIImage(systemName: "person.2.fill")?.withConfiguration(
UIImage.SymbolConfiguration(pointSize: 24, weight: .medium)
)
groupIconView.tintColor = .white.withAlphaComponent(0.9)
let mantineDarkBody = UIColor(red: 0x1A/255, green: 0x1B/255, blue: 0x1E/255, alpha: 1)
let baseColor = isDark ? mantineDarkBody : .white
let tintUIColor = UIColor(colorPair.tint)
let tintAlpha: CGFloat = isDark ? 0.15 : 0.10
avatarBackgroundView.backgroundColor = baseColor.blended(with: tintUIColor, alpha: tintAlpha)
avatarInitialsLabel.isHidden = false
avatarInitialsLabel.text = dialog.initials
avatarInitialsLabel.font = .systemFont(
ofSize: CellLayout.avatarDiameter * 0.38, weight: .bold
).rounded()
avatarInitialsLabel.textColor = isDark
? UIColor(colorPair.text)
: tintUIColor
} else {
// Initials Mantine "light" variant (matches AvatarView.swift)
let mantineDarkBody = UIColor(red: 0x1A/255, green: 0x1B/255, blue: 0x1E/255, alpha: 1)
@@ -689,10 +703,10 @@ final class ChatListCell: UICollectionViewCell {
authorLabel.isHidden = false
authorLabel.text = senderName
authorLabel.textColor = titleColor
messageLabel.numberOfLines = 1 // 1 line when author shown
messageLabel.numberOfLines = 1 // always single-line subtitle
} else {
authorLabel.isHidden = true
messageLabel.numberOfLines = 2
messageLabel.numberOfLines = 1
}
messageLabel.attributedText = nil
@@ -861,7 +875,7 @@ final class ChatListCell: UICollectionViewCell {
onlineIndicator.isHidden = true
contentView.backgroundColor = .clear
messageLabel.attributedText = nil
messageLabel.numberOfLines = 2
messageLabel.numberOfLines = 1
authorLabel.isHidden = true
// Typing indicator
typingDotsView.stopAnimating()
@@ -873,6 +887,7 @@ final class ChatListCell: UICollectionViewCell {
badgeContainer.transform = .identity
mentionImageView.transform = .identity
isSystemChat = false
isSeparatorFullWidth = false
}
// MARK: - Highlight
@@ -909,7 +924,13 @@ final class ChatListCell: UICollectionViewCell {
// MARK: - Separator Visibility
func setSeparatorHidden(_ hidden: Bool) {
setSeparatorStyle(hidden: hidden, fullWidth: false)
}
func setSeparatorStyle(hidden: Bool, fullWidth: Bool) {
separatorView.isHidden = hidden
isSeparatorFullWidth = fullWidth
setNeedsLayout()
}
}

View File

@@ -25,6 +25,7 @@ final class ChatListCollectionController: UIViewController {
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)?
@@ -38,6 +39,11 @@ final class ChatListCollectionController: UIViewController {
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
private var searchHeaderExpansion: CGFloat = 1.0
private var hasInitializedTopOffset = false
private var isPinnedFractionReportScheduled = false
// MARK: - UI
@@ -47,13 +53,7 @@ final class ChatListCollectionController: UIViewController {
private var requestsCellRegistration: UICollectionView.CellRegistration<ChatListRequestsCell, Int>!
private let floatingTabBarTotalHeight: CGFloat = 72
private var chatListBottomInset: CGFloat {
if #available(iOS 26, *) {
return 0
} else {
// contentInsetAdjustmentBehavior(.automatic) already contributes safe-area bottom.
// Add only the remaining space covered by the custom floating tab bar.
return max(0, floatingTabBarTotalHeight - view.safeAreaInsets.bottom)
}
floatingTabBarTotalHeight
}
// Dialog lookup by ID for cell configuration
@@ -63,20 +63,24 @@ final class ChatListCollectionController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .clear
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 = .clear
collectionView.backgroundColor = UIColor(RosettaColors.Adaptive.background)
collectionView.delegate = self
collectionView.prefetchDataSource = self
collectionView.keyboardDismissMode = .onDrag
@@ -84,8 +88,8 @@ final class ChatListCollectionController: UIViewController {
collectionView.showsHorizontalScrollIndicator = false
collectionView.alwaysBounceVertical = true
collectionView.alwaysBounceHorizontal = false
collectionView.contentInsetAdjustmentBehavior = .automatic
applyBottomInsets()
collectionView.contentInsetAdjustmentBehavior = .never
applyInsets()
view.addSubview(collectionView)
NSLayoutConstraint.activate([
@@ -98,19 +102,99 @@ final class ChatListCollectionController: UIViewController {
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
applyBottomInsets()
applyInsets()
if !hasInitializedTopOffset {
collectionView.setContentOffset(
CGPoint(x: 0, y: -collectionView.contentInset.top),
animated: false
)
hasInitializedTopOffset = true
}
schedulePinnedHeaderFractionReport()
}
override func viewSafeAreaInsetsDidChange() {
super.viewSafeAreaInsetsDidChange()
applyBottomInsets()
applyInsets()
}
private func applyBottomInsets() {
private func applyInsets() {
guard collectionView != nil else { return }
let inset = chatListBottomInset
collectionView.contentInset.bottom = inset
collectionView.verticalScrollIndicatorInsets.bottom = inset
let oldTopInset = collectionView.contentInset.top
let topInset = view.safeAreaInsets.top + (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 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 }
var maxPinnedOffset: CGFloat = 0.0
for cell in collectionView.visibleCells {
guard let indexPath = collectionView.indexPath(for: cell),
sectionForIndexPath(indexPath) == .pinned else {
continue
}
maxPinnedOffset = max(maxPinnedOffset, cell.frame.maxY)
}
let viewportInsetTop = collectionView.contentInset.top
guard viewportInsetTop > 0 else { return 0.0 }
if maxPinnedOffset >= viewportInsetTop {
return 1.0
}
return max(0.0, min(1.0, maxPinnedOffset / 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 {
@@ -159,16 +243,26 @@ final class ChatListCollectionController: UIViewController {
guard let self else { return }
let typingUsers = self.typingDialogs[dialog.opponentKey]
cell.configure(with: dialog, isSyncing: self.isSyncing, typingUsers: typingUsers)
// Hide separator for last cell in pinned/unpinned section
// 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
cell.setSeparatorHidden(isLastInPinned || isLastInUnpinned)
if isLastInPinned {
cell.setSeparatorStyle(hidden: false, fullWidth: true)
} else {
cell.setSeparatorStyle(hidden: isLastInUnpinned, fullWidth: false)
}
}
requestsCellRegistration = UICollectionView.CellRegistration<ChatListRequestsCell, Int> {
cell, indexPath, count in
cell.configure(count: count)
[weak self] cell, _, count in
let hasPinned = !(self?.pinnedDialogs.isEmpty ?? true)
// Requests row separator should be hidden when pinned section exists.
cell.configure(count: count, showBottomSeparator: !hasPinned)
}
}
@@ -245,16 +339,18 @@ final class ChatListCollectionController: UIViewController {
snapshot.appendSections([.unpinned])
snapshot.appendItems(newUnpinnedIds, toSection: .unpinned)
dataSource.apply(snapshot, animatingDifferences: true)
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 SwiftUI about pinned state
DispatchQueue.main.async { [weak self] in
self?.onPinnedStateChange?(!pinned.isEmpty)
}
// Notify host immediately so top chrome reacts in the same frame.
onPinnedStateChange?(!pinned.isEmpty)
}
/// Directly reconfigure only visible cells no snapshot rebuild, no animation.
@@ -268,7 +364,7 @@ final class ChatListCollectionController: UIViewController {
let typingUsers = typingDialogs[dialog.opponentKey]
chatCell.configure(with: dialog, isSyncing: isSyncing, typingUsers: typingUsers)
} else if let reqCell = cell as? ChatListRequestsCell {
reqCell.configure(count: requestsCount)
reqCell.configure(count: requestsCount, showBottomSeparator: pinnedDialogs.isEmpty)
}
}
}
@@ -369,11 +465,29 @@ extension ChatListCollectionController: UICollectionViewDelegate {
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 + scrollView.adjustedContentInset.top
let expansion = max(0.0, min(1.0, 1.0 - offset / 54.0))
guard abs(expansion - lastReportedExpansion) > 0.005 else { return }
lastReportedExpansion = expansion
onScrollOffsetChange?(expansion)
let offset = scrollView.contentOffset.y + view.safeAreaInsets.top + searchCollapseDistance
let expansion = max(0.0, min(1.0, 1.0 - offset / searchCollapseDistance))
if abs(expansion - lastReportedExpansion) > 0.005 {
lastReportedExpansion = expansion
onScrollOffsetChange?(expansion)
}
reportPinnedHeaderFraction()
}
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 safeTop = view.safeAreaInsets.top
if lastReportedExpansion < 0.5 {
targetContentOffset.pointee.y = -safeTop
} else {
targetContentOffset.pointee.y = -(safeTop + searchCollapseDistance)
}
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
@@ -529,7 +643,7 @@ final class ChatListRequestsCell: UICollectionViewCell {
separatorView.frame = CGRect(x: 80, y: h - sepH, width: w - 80, height: sepH)
}
func configure(count: Int) {
func configure(count: Int, showBottomSeparator: Bool) {
let isDark = traitCollection.userInterfaceStyle == .dark
titleLabel.textColor = isDark ? .white : .black
subtitleLabel.text = count == 1 ? "1 request" : "\(count) requests"
@@ -537,6 +651,7 @@ final class ChatListRequestsCell: UICollectionViewCell {
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(

File diff suppressed because it is too large Load Diff