Files
mobile-ios/Rosetta/Features/Chats/ChatList/ChatListView.swift

878 lines
35 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 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() {
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)
}