Фикс: сделал subtitle в списке чатов и текст in-app баннера в одну строку с truncate
This commit is contained in:
@@ -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 0→1 re-fires .onAppear).
|
||||
consumePendingRouteIfFresh()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
|
||||
// Background→foreground fallback: covers edge cases where .onReceive
|
||||
// subscription hasn't re-activated after background→foreground 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
1538
Rosetta/Features/Chats/ChatList/UIKit/ChatListUIKitView.swift
Normal file
1538
Rosetta/Features/Chats/ChatList/UIKit/ChatListUIKitView.swift
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user