878 lines
35 KiB
Swift
878 lines
35 KiB
Swift
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)
|
||
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
|
||
@FocusState private var isSearchFocused: Bool
|
||
|
||
var body: some View {
|
||
NavigationStack(path: $navigationState.path) {
|
||
VStack(spacing: 0) {
|
||
// Custom search bar
|
||
customSearchBar
|
||
.padding(.horizontal, 16)
|
||
.padding(.top, 12)
|
||
.padding(.bottom, 8)
|
||
.background(
|
||
(hasPinnedChats && !isSearchActive
|
||
? RosettaColors.Dark.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: searchText) { _, newValue in
|
||
viewModel.setSearchQuery(newValue)
|
||
}
|
||
.navigationDestination(for: ChatRoute.self) { route in
|
||
ChatDetailView(
|
||
route: route,
|
||
onPresentedChange: { presented in
|
||
isDetailPresented = presented
|
||
}
|
||
)
|
||
}
|
||
.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])
|
||
.preferredColorScheme(.dark)
|
||
}
|
||
.sheet(isPresented: $showJoinGroupSheet) {
|
||
NavigationStack {
|
||
GroupJoinView { route in
|
||
showJoinGroupSheet = false
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||
navigationState.path = [route]
|
||
}
|
||
}
|
||
}
|
||
.presentationDetents([.large])
|
||
.preferredColorScheme(.dark)
|
||
}
|
||
.onReceive(NotificationCenter.default.publisher(for: .openChatFromNotification)) { notification in
|
||
guard let route = notification.object as? ChatRoute else { return }
|
||
// Navigate to the chat from push notification tap (fast path)
|
||
navigationState.path = [route]
|
||
AppDelegate.pendingChatRoute = nil
|
||
AppDelegate.pendingChatRouteTimestamp = nil
|
||
}
|
||
.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() {
|
||
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: 42)
|
||
.frame(maxWidth: .infinity)
|
||
.contentShape(Rectangle())
|
||
.onTapGesture {
|
||
if !isSearchActive {
|
||
withAnimation(.easeInOut(duration: 0.25)) {
|
||
isSearchActive = true
|
||
}
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||
isSearchFocused = true
|
||
}
|
||
}
|
||
}
|
||
.background {
|
||
if isSearchActive {
|
||
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||
.fill(Color.white.opacity(0.08))
|
||
.overlay {
|
||
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||
.strokeBorder(Color.white.opacity(0.06), lineWidth: 0.5)
|
||
}
|
||
} else {
|
||
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||
.fill(RosettaColors.Adaptive.backgroundSecondary)
|
||
}
|
||
}
|
||
.onChange(of: isSearchFocused) { _, focused in
|
||
if focused && !isSearchActive {
|
||
withAnimation(.easeInOut(duration: 0.25)) {
|
||
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(.white)
|
||
.frame(width: 36, height: 36)
|
||
.padding(3)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.background {
|
||
Circle()
|
||
.fill(Color.white.opacity(0.08))
|
||
.overlay {
|
||
Circle()
|
||
.strokeBorder(Color.white.opacity(0.06), 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
|
||
}
|
||
}
|
||
}
|
||
)
|
||
}
|
||
}
|
||
|
||
// 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(.white)
|
||
Text("Syncing conversations…")
|
||
.font(.system(size: 15))
|
||
.foregroundStyle(Color.white.opacity(0.5))
|
||
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 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
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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 }
|
||
|
||
/// Desktop parity: track typing dialogs from MessageRepository (@Published).
|
||
@State private var typingDialogs: [String: Set<String>] = [:]
|
||
|
||
var body: some View {
|
||
let _ = PerformanceLogger.shared.track("chatList.bodyEval")
|
||
// 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
|
||
|
||
private static let topAnchorId = "chatlist_top"
|
||
|
||
private func dialogList(pinned: [Dialog], unpinned: [Dialog], requestsCount: Int) -> some View {
|
||
ScrollViewReader { scrollProxy in
|
||
List {
|
||
if viewModel.isLoading {
|
||
ForEach(0..<8, id: \.self) { _ in
|
||
ChatRowShimmerView()
|
||
.listRowInsets(EdgeInsets())
|
||
.listRowBackground(Color.clear)
|
||
.listRowSeparator(.hidden)
|
||
}
|
||
} else {
|
||
// Telegram-style "Request Chats" row at top (like Archived Chats)
|
||
if requestsCount > 0 {
|
||
RequestChatsRow(count: requestsCount, onTap: onShowRequests)
|
||
.listRowInsets(EdgeInsets())
|
||
.listRowBackground(Color.clear)
|
||
.listRowSeparator(.visible, edges: .bottom)
|
||
.listRowSeparatorTint(RosettaColors.Adaptive.divider)
|
||
.alignmentGuide(.listRowSeparatorLeading) { _ in 82 }
|
||
}
|
||
|
||
if !pinned.isEmpty {
|
||
ForEach(pinned, id: \.id) { dialog in
|
||
chatRow(dialog, isFirst: dialog.id == pinned.first?.id && requestsCount == 0)
|
||
.environment(\.rowBackgroundColor, RosettaColors.Dark.pinnedSectionBackground)
|
||
.listRowBackground(RosettaColors.Dark.pinnedSectionBackground)
|
||
}
|
||
}
|
||
ForEach(unpinned, id: \.id) { dialog in
|
||
chatRow(dialog, isFirst: dialog.id == unpinned.first?.id && pinned.isEmpty && requestsCount == 0)
|
||
}
|
||
}
|
||
|
||
Color.clear.frame(height: 80)
|
||
.listRowInsets(EdgeInsets())
|
||
.listRowBackground(Color.clear)
|
||
.listRowSeparator(.hidden)
|
||
}
|
||
.listStyle(.plain)
|
||
.scrollContentBackground(.hidden)
|
||
.scrollDismissesKeyboard(.immediately)
|
||
.scrollIndicators(.hidden)
|
||
.modifier(ClassicSwipeActionsModifier())
|
||
// Scroll-to-top: tap "Chats" in toolbar
|
||
.onReceive(NotificationCenter.default.publisher(for: .chatListScrollToTop)) { _ in
|
||
// Scroll to first dialog ID (pinned or unpinned)
|
||
let firstId = pinned.first?.id ?? unpinned.first?.id
|
||
if let firstId {
|
||
withAnimation(.easeOut(duration: 0.3)) {
|
||
scrollProxy.scrollTo(firstId, anchor: .top)
|
||
}
|
||
}
|
||
}
|
||
} // ScrollViewReader
|
||
}
|
||
|
||
private func chatRow(_ dialog: Dialog, isFirst: Bool = false) -> some View {
|
||
/// Desktop parity: wrap in SyncAwareChatRow to isolate @Observable read
|
||
/// of SessionManager.syncBatchInProgress from this view's observation scope.
|
||
/// viewModel + navigationState passed as plain `let` (not @ObservedObject) —
|
||
/// stable class references don't trigger row re-evaluation on parent re-render.
|
||
SyncAwareChatRow(
|
||
dialog: dialog,
|
||
isTyping: !(typingDialogs[dialog.opponentKey]?.isEmpty ?? true),
|
||
typingSenderNames: {
|
||
guard let senderKeys = typingDialogs[dialog.opponentKey] else { return [] }
|
||
return senderKeys.map { sk in
|
||
DialogRepository.shared.dialogs[sk]?.opponentTitle
|
||
?? String(sk.prefix(8))
|
||
}
|
||
}(),
|
||
isFirst: isFirst,
|
||
viewModel: viewModel,
|
||
navigationState: navigationState
|
||
)
|
||
}
|
||
}
|
||
|
||
// MARK: - Sync-Aware Chat Row (observation-isolated)
|
||
|
||
/// Reads `SessionManager.syncBatchInProgress` (@Observable) in its own
|
||
/// observation scope. Without this wrapper, every sync state change would
|
||
/// invalidate the entire `ChatListDialogContent.body` and rebuild all rows.
|
||
/// Reads `SessionManager.syncBatchInProgress` (@Observable) in its own
|
||
/// observation scope. Without this wrapper, every sync state change would
|
||
/// invalidate the entire `ChatListDialogContent.body` and rebuild all rows.
|
||
///
|
||
/// **Performance:** `viewModel` and `navigationState` are stored as plain `let`
|
||
/// (not @ObservedObject). Class references compare by pointer in SwiftUI's
|
||
/// memcmp-based view diffing — stable pointers mean unchanged rows are NOT
|
||
/// re-evaluated when the parent body rebuilds. Closures are defined inline
|
||
/// (not passed from parent) to avoid non-diffable closure props that force
|
||
/// every row dirty on every parent re-render.
|
||
struct SyncAwareChatRow: View {
|
||
let dialog: Dialog
|
||
let isTyping: Bool
|
||
let typingSenderNames: [String]
|
||
let isFirst: Bool
|
||
let viewModel: ChatListViewModel
|
||
let navigationState: ChatListNavigationState
|
||
|
||
var body: some View {
|
||
let isSyncing = SessionManager.shared.syncBatchInProgress
|
||
Button {
|
||
navigationState.path.append(ChatRoute(dialog: dialog))
|
||
} label: {
|
||
ChatRowView(
|
||
dialog: dialog,
|
||
isSyncing: isSyncing,
|
||
isTyping: isTyping,
|
||
typingSenderNames: typingSenderNames
|
||
)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.listRowInsets(EdgeInsets())
|
||
.listRowSeparator(isFirst ? .hidden : .visible, edges: .top)
|
||
.listRowSeparator(.visible, edges: .bottom)
|
||
.listRowSeparatorTint(RosettaColors.Adaptive.divider)
|
||
.alignmentGuide(.listRowSeparatorLeading) { _ in 82 }
|
||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||
Button(role: .destructive) {
|
||
withAnimation { viewModel.deleteDialog(dialog) }
|
||
} label: {
|
||
Label("Delete", systemImage: "trash")
|
||
}
|
||
|
||
if !dialog.isSavedMessages {
|
||
Button {
|
||
withAnimation { viewModel.toggleMute(dialog) }
|
||
} label: {
|
||
Label(
|
||
dialog.isMuted ? "Unmute" : "Mute",
|
||
systemImage: dialog.isMuted ? "bell" : "bell.slash"
|
||
)
|
||
}
|
||
.tint(dialog.isMuted ? .green : .indigo)
|
||
}
|
||
}
|
||
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
||
Button {
|
||
withAnimation { viewModel.togglePin(dialog) }
|
||
} label: {
|
||
Label(dialog.isPinned ? "Unpin" : "Pin", systemImage: dialog.isPinned ? "pin.slash" : "pin")
|
||
}
|
||
.tint(.orange)
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
// 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)
|
||
}
|
||
|