import Foundation import SwiftData import SwiftUI /// Telegram-style main screen. On iOS the root is a bottom tab bar /// (Контакты / Звонки / Чаты / Настройки) with an inline search field at /// the top of the chat and contact lists. On macOS the root is a Telegram /// Desktop-style split view: a sidebar (menu, search, list) plus a detail /// pane with the selected chat. struct MainTabView: View { @ObservedObject var model: AppModel var body: some View { #if os(macOS) MainSplitView(model: model) #else MainTabBarView(model: model) #endif } } // MARK: - Shared search field /// Inline rounded search field, placed explicitly above the list it /// filters. Used on both platforms so the position never depends on /// navigation-bar search placement. private struct SearchField: View { @Binding var text: String let prompt: String var body: some View { HStack(spacing: 6) { Image(systemName: "magnifyingglass") .font(.system(size: 13)) .foregroundStyle(.secondary) TextField(prompt, text: $text) .textFieldStyle(.plain) .autocorrectionDisabled() #if os(iOS) .textInputAutocapitalization(.never) #endif if !text.isEmpty { Button { text = "" } label: { Image(systemName: "xmark.circle.fill") .font(.system(size: 13)) .foregroundStyle(.secondary) } .buttonStyle(.plain) .accessibilityLabel("Очистить поиск") } } .padding(.horizontal, 10) .padding(.vertical, 7) .glassEffect() // .background( // Color.secondary.opacity(0.14), // in: RoundedRectangle(cornerRadius: 10) // ) .padding(.horizontal, 12) .padding(.vertical, 6) } } // MARK: - iOS: bottom tab bar #if os(iOS) private struct MainTabBarView: View { enum Tab: String, CaseIterable, Identifiable { case contacts case calls case chats case settings var id: String { rawValue } var title: String { switch self { case .contacts: return "Контакты" case .calls: return "Звонки" case .chats: return "Чаты" case .settings: return "Настройки" } } var icon: String { switch self { case .contacts: return "person.circle.fill" case .calls: return "phone.fill" case .chats: return "bubble.left.and.bubble.right.fill" case .settings: return "gearshape.fill" } } var selectedIcon: String { switch self { case .contacts: return "person.circle.fill" case .calls: return "phone.fill" case .chats: return "bubble.left.and.bubble.right.fill" case .settings: return "gearshape.fill" } } } @ObservedObject var model: AppModel @State private var selectedTab: Tab = .chats var body: some View { // Native TabView: on iOS 26 it renders the system floating // translucent tab bar (Liquid Glass). TabView(selection: $selectedTab) { ForEach(Tab.allCases) { tab in tabContent(for: tab) .tabItem { Label( tab.title, systemImage: selectedTab == tab ? tab.selectedIcon : tab.icon ) } .tag(tab) } } } @ViewBuilder private func tabContent(for tab: Tab) -> some View { switch tab { case .contacts: ContactsTab(model: model) case .calls: CallsTab(model: model) case .chats: ChatsTab(model: model) case .settings: NavigationStack { SettingsView(model: model, presentedAsTab: true) } } } } #endif // MARK: - Chats private struct ChatsTab: View { @ObservedObject var model: AppModel @State private var searchText = "" @State private var showingNewChat = false @State private var showingNewGroup = false @State private var pendingGroupDeletion: Conversation? @FocusState private var searchFocused: Bool /// SwiftData-backed chat list. The query orders by last activity; /// `filteredConversations` applies the pinned-first ordering that /// matches `AppModel.sortConversations` (`Bool` is not `Comparable`, /// so pinning cannot be a `SortDescriptor`). @Query( sort: [ SortDescriptor(\Conversation.lastActivity, order: .reverse) ] ) private var conversations: [Conversation] var body: some View { NavigationStack { VStack(spacing: 0) { chatList } .navigationTitle("") #if os(iOS) .navigationBarTitleDisplayMode(.inline) .searchable( text: $searchText, placement: .navigationBarDrawer(displayMode: .always), prompt: "Поиск чатов" ) #endif .toolbar { ToolbarItem(placement: .principal) { ConnectionBanner(model: model, text: "Чаты") } ToolbarItemGroup(placement: .primaryAction) { Button { showingNewGroup = true } label: { Image(systemName: "person.3.fill") } .accessibilityLabel("Новый групповой чат") .help("Новый групповой чат") Button { showingNewChat = true } label: { Image(systemName: "square.and.pencil") } .accessibilityLabel("Новый личный чат") .help("Новый личный чат") } } .sheet(isPresented: $showingNewChat) { NewChatView(model: model) } .sheet(isPresented: $showingNewGroup) { NewGroupView(model: model) } .alert( "Удалить групповой чат?", isPresented: Binding( get: { pendingGroupDeletion != nil }, set: { if !$0 { pendingGroupDeletion = nil } } ) ) { Button("Удалить", role: .destructive) { if let pendingGroupDeletion { model.deleteGroupChat(jid: pendingGroupDeletion.jid) } pendingGroupDeletion = nil } Button("Отмена", role: .cancel) { pendingGroupDeletion = nil } } message: { Text( "Чат и его история будут удалены с этого устройства. Luma выйдет из комнаты, если вы в ней." ) } } } private var filteredConversations: [Conversation] { let source = searchText.isEmpty ? conversations : conversations.filter(matchesSearch) return source.sorted(by: conversationSort) } private var chatList: some View { List { ForEach(filteredConversations) { conversation in NavigationLink { ChatView(model: model, conversation: conversation) .id(conversation.jid) .onAppear { model.selectConversation(id: conversation.jid) } } label: { ConversationRow( conversation: conversation, imageData: model.avatarData(for: conversation.jid), isEncrypted: model.encryptionEnabled( for: conversation.jid ) ) } .listRowInsets( EdgeInsets(top: 6, leading: 12, bottom: 6, trailing: 12) ) .swipeActions(edge: .trailing, allowsFullSwipe: false) { if conversation.isGroup { Button(role: .destructive) { pendingGroupDeletion = conversation } label: { Label("Удалить", systemImage: "trash") } } } } } .listStyle(.plain) .overlay { if filteredConversations.isEmpty { ContentUnavailableView( searchText.isEmpty ? "Нет чатов" : "Ничего не найдено", systemImage: searchText.isEmpty ? "bubble.left" : "magnifyingglass", description: Text( searchText.isEmpty ? "Создайте личный или групповой чат." : "Попробуйте другой JID или имя." ) ) } } } private func conversationSort(_ lhs: Conversation, _ rhs: Conversation) -> Bool { if lhs.isPinned != rhs.isPinned { return lhs.isPinned } if lhs.lastActivity != rhs.lastActivity { return lhs.lastActivity > rhs.lastActivity } return lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) == .orderedAscending } private func matchesSearch(_ conversation: Conversation) -> Bool { conversation.displayName.localizedCaseInsensitiveContains(searchText) || conversation.jid.localizedCaseInsensitiveContains(searchText) } } // MARK: - Contacts private struct ContactsTab: View { @ObservedObject var model: AppModel @State private var searchText = "" @Query( sort: [ SortDescriptor(\Conversation.lastActivity, order: .reverse) ] ) private var conversations: [Conversation] var body: some View { NavigationStack { VStack(spacing: 0) { contactsList } .navigationTitle("") #if os(iOS) .navigationBarTitleDisplayMode(.inline) .searchable( text: $searchText, placement: .navigationBarDrawer(displayMode: .always), prompt: "Поиск контактов" ) #endif .toolbar { ToolbarItem(placement: .principal) { ConnectionBanner(model: model, text: "Контакты") } } } } private var contactsList: some View { List { if !filteredRosterContacts.isEmpty { Section { ForEach(filteredRosterContacts) { conversation in contactLink(conversation) .listRowInsets( EdgeInsets( top: 7, leading: 12, bottom: 7, trailing: 12 ) ) } } } if !filteredGroupContacts.isEmpty { Section { ForEach(filteredGroupContacts) { conversation in contactLink(conversation) .listRowInsets( EdgeInsets( top: 7, leading: 12, bottom: 7, trailing: 12 ) ) } } } } .listStyle(.plain) .overlay { if filteredRosterContacts.isEmpty && filteredGroupContacts.isEmpty { ContentUnavailableView( searchText.isEmpty ? "Нет контактов" : "Ничего не найдено", systemImage: searchText.isEmpty ? "person.2" : "magnifyingglass", description: Text(contactListDescription), ) } } } @ViewBuilder private func contactLink(_ conversation: Conversation) -> some View { NavigationLink { ChatView(model: model, conversation: conversation) .id(conversation.jid) .onAppear { model.selectConversation(id: conversation.jid) } } label: { ContactRow( conversation: conversation, imageData: model.avatarData(for: conversation.jid) ) } } private var filteredRosterContacts: [Conversation] { conversations .filter { !$0.isGroup && model.rosterContactJIDs.contains($0.jid) } .filter { searchText.isEmpty || matchesSearch($0) } .sorted(by: contactSort) } private var filteredGroupContacts: [Conversation] { conversations .filter { $0.isGroup } .filter { searchText.isEmpty || matchesSearch($0) } .sorted(by: contactSort) } private var hasStoredContacts: Bool { !model.rosterContactJIDs.isEmpty || conversations.contains { $0.isGroup } } private var contactListDescription: String { if !searchText.isEmpty { return "Попробуйте другой JID или имя." } if !hasStoredContacts, model.connectionStatus != .connected { return "Подключитесь к серверу, чтобы загрузить roster Prosody и групповые комнаты." } return "Добавьте человека в roster или создайте групповой чат." } private func matchesSearch(_ conversation: Conversation) -> Bool { conversation.displayName.localizedCaseInsensitiveContains(searchText) || conversation.jid.localizedCaseInsensitiveContains(searchText) } private func contactSort(_ lhs: Conversation, _ rhs: Conversation) -> Bool { lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) == .orderedAscending } } // MARK: - Calls private struct CallsTab: View { @ObservedObject var model: AppModel var body: some View { NavigationStack { Group { if model.callHistoryMessages.isEmpty { ContentUnavailableView( "Нет звонков", systemImage: "phone", description: Text( "История звонков появится здесь после первого вызова." ) ) } else { List(model.callHistoryMessages) { message in if let conversation = model.conversations.first(where: { $0.jid == message.conversationID }) { NavigationLink { ChatView( model: model, conversation: conversation ) .id(conversation.jid) .onAppear { model.selectConversation( id: conversation.jid ) } } label: { CallHistoryRow( model: model, message: message, conversation: conversation ) } .listRowInsets( EdgeInsets( top: 7, leading: 12, bottom: 7, trailing: 12 ) ) } else { CallHistoryRow( model: model, message: message, conversation: nil ) .listRowInsets( EdgeInsets( top: 7, leading: 12, bottom: 7, trailing: 12 ) ) } } .listStyle(.plain) } } .navigationTitle("") #if os(iOS) .navigationBarTitleDisplayMode(.inline) #endif .toolbar { ToolbarItem(placement: .principal) { Text("Звонки") .font(.headline) } } } } } private struct CallHistoryRow: View { @ObservedObject var model: AppModel let message: ChatMessage let conversation: Conversation? var body: some View { HStack(spacing: 12) { Image( systemName: message.callHistory?.isVideo == true ? "video.fill" : "phone.fill" ) .font(.system(size: 17, weight: .semibold)) .foregroundStyle( message.direction == .incoming && message.callHistory?.outcome == .missed ? Color.red : Color.accentColor ) .frame(width: 34, height: 34) .background(Color.secondary.opacity(0.12), in: Circle()) VStack(alignment: .leading, spacing: 2) { Text(conversation?.displayName ?? message.conversationID) .font(.headline) .lineLimit(1) Text(message.callTitle) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) } Spacer(minLength: 8) Text(message.timestamp, format: .dateTime.hour().minute()) .font(.caption) .foregroundStyle(.secondary) } .contentShape(Rectangle()) } } private struct ContactRow: View { let conversation: Conversation let imageData: Data? var body: some View { HStack(spacing: 12) { AvatarView( conversation: conversation, imageData: imageData, size: 42 ) VStack(alignment: .leading, spacing: 3) { HStack(spacing: 6) { Text(conversation.displayName) .font(.headline) .lineLimit(1) if !conversation.isGroup, conversation.isOnline { Circle() .fill(Color.green) .frame(width: 7, height: 7) .accessibilityLabel("В сети") } } if conversation.isGroup { HStack(spacing: 4) { Image(systemName: "person.3.fill") Text(groupSubtitle) } .font(.caption) .foregroundStyle(.secondary) } else { Text(conversation.jid) .font(.subheadline) .foregroundStyle(.secondary) .lineLimit(1) } } Spacer(minLength: 8) // Image(systemName: "chevron.right") // .font(.caption.bold()) // .foregroundStyle(.tertiary) } .contentShape(Rectangle()) } private var groupSubtitle: String { guard conversation.occupantCount > 0 else { return conversation.jid } return "\(conversation.occupantCount) участн. • \(conversation.jid)" } } // MARK: - macOS: Telegram Desktop-style split view #if os(macOS) private struct MainSplitView: View { private enum Mode: String, CaseIterable, Identifiable { case contacts case calls case chats case settings var id: String { rawValue } var title: String { switch self { case .chats: return "Чаты" case .contacts: return "Контакты" case .calls: return "Звонки" case .settings: return "Настройки" } } var icon: String { switch self { case .chats: return "bubble.left.and.bubble.right.fill" case .contacts: return "person.circle.fill" case .calls: return "phone.fill" case .settings: return "gearshape.fill" } } var searchPrompt: String { switch self { case .chats: return "Поиск чатов" case .contacts: return "Поиск контактов" case .calls: return "Поиск звонков" case .settings: return "Поиск" } } } @ObservedObject var model: AppModel @State private var mode: Mode = .chats @State private var selectedJID: String? @State private var searchText = "" @State private var showingNewChat = false @State private var showingNewGroup = false @State private var pendingGroupDeletion: Conversation? /// SwiftData-backed chat list; pinned-first ordering is applied in /// `sortedChats` because `Bool` is not `Comparable` and cannot be a /// `SortDescriptor`. @Query( sort: [ SortDescriptor(\Conversation.lastActivity, order: .reverse) ] ) private var conversations: [Conversation] var body: some View { NavigationSplitView { sidebar .navigationSplitViewColumnWidth( min: 240, ideal: 290, max: 380 ) .toolbar { ToolbarItem(placement: .principal) { Text(mode.title) .font(.headline) } if mode != .settings { ToolbarItemGroup(placement: .primaryAction) { Button { showingNewGroup = true } label: { Image(systemName: "person.3.fill") } .accessibilityLabel("Новый групповой чат") .help("Новый групповой чат") Button { showingNewChat = true } label: { Image(systemName: "square.and.pencil") } .accessibilityLabel("Новый личный чат") .help("Новый личный чат") } } } } detail: { detailPane } .frame(minWidth: 840, minHeight: 560) .sheet(isPresented: $showingNewChat) { NewChatView(model: model) } .sheet(isPresented: $showingNewGroup) { NewGroupView(model: model) } .alert( "Удалить групповой чат?", isPresented: Binding( get: { pendingGroupDeletion != nil }, set: { if !$0 { pendingGroupDeletion = nil } } ) ) { Button("Удалить", role: .destructive) { if let pendingGroupDeletion { model.deleteGroupChat(jid: pendingGroupDeletion.jid) } pendingGroupDeletion = nil } Button("Отмена", role: .cancel) { pendingGroupDeletion = nil } } message: { Text( "Чат и его история будут удалены с этого устройства. Luma выйдет из комнаты, если вы в ней." ) } .onAppear { ensureSelection() } } // MARK: Sidebar private var sidebar: some View { VStack(spacing: 0) { if mode != .settings { ConnectionBanner(model: model, text: "") SearchField( text: $searchText, prompt: mode.searchPrompt ) } sidebarList } .safeAreaInset(edge: .bottom) { HStack(spacing: 0) { ForEach(Mode.allCases) { item in Button { withAnimation(.easeInOut) { switchMode(item) } } label: { VStack { Image(systemName: item.icon) .font(.largeTitle) } } .frame(maxWidth: .infinity) .foregroundColor( mode == item ? .accentColor : .secondary ) .padding(.vertical, 8) .buttonStyle(.plain) } } .animation( .spring(response: 0.35, dampingFraction: 0.82), value: mode ) .glassEffect(in: .rect(cornerRadius: 20)) } } private var sidebarList: some View { List(selection: $selectedJID) { switch mode { case .chats: chatRows case .contacts: contactRows case .calls: callRows case .settings: // NavigationStack { SettingsView(model: model, presentedAsTab: true) // } // EmptyView() } } .listStyle(.sidebar) .overlay { if mode == .chats && filteredChats.isEmpty { ContentUnavailableView( searchText.isEmpty ? "Нет чатов" : "Ничего не найдено", systemImage: searchText.isEmpty ? "bubble.left" : "magnifyingglass", description: Text( searchText.isEmpty ? "Создайте личный или групповой чат." : "Попробуйте другой JID или имя." ) ) } if mode == .contacts && filteredRoster.isEmpty && filteredGroups.isEmpty { ContentUnavailableView( searchText.isEmpty ? "Нет контактов" : "Ничего не найдено", systemImage: searchText.isEmpty ? "person.2" : "magnifyingglass", description: Text( searchText.isEmpty ? "Подключитесь к серверу, чтобы загрузить roster, или создайте групповой чат." : "Попробуйте другой JID или имя." ) ) } } } @ViewBuilder private var chatRows: some View { ForEach(filteredChats) { conversation in ConversationRow( conversation: conversation, imageData: model.avatarData(for: conversation.jid), isEncrypted: model.encryptionEnabled(for: conversation.jid) ) .tag(conversation.jid) .contextMenu { if conversation.isGroup { Button("Удалить", role: .destructive) { pendingGroupDeletion = conversation } } } } } @ViewBuilder private var contactRows: some View { if !filteredRoster.isEmpty { Section { ForEach(filteredRoster) { conversation in ContactRow( conversation: conversation, imageData: model.avatarData(for: conversation.jid) ) .tag(conversation.jid) } } } if !filteredGroups.isEmpty { Section { ForEach(filteredGroups) { conversation in ContactRow( conversation: conversation, imageData: model.avatarData(for: conversation.jid) ) .tag(conversation.jid) } } } } @ViewBuilder private var callRows: some View { ForEach(filteredCalls) { message in if let conversation = conversations.first(where: { $0.jid == message.conversationID }) { CallHistoryRow( model: model, message: message, conversation: conversation ) .tag(message.clientID) } else { CallHistoryRow( model: model, message: message, conversation: nil ) .tag(message.clientID) } } } // MARK: Detail pane @ViewBuilder private var detailPane: some View { if mode == .settings { ContentUnavailableView( emptyTitle, systemImage: "gearshape.fill", description: Text("Выберите пункт из списка слева.") ) } else if let conversation = selectedConversation ?? defaultConversation { ChatView(model: model, conversation: conversation) .id(conversation.jid) .onAppear { model.selectConversation(id: conversation.jid) } } else { ContentUnavailableView( emptyTitle, systemImage: "bubble.left.and.bubble.right", description: Text("Выберите пункт из списка слева.") ) } } private var emptyTitle: String { switch mode { case .chats: return "Выберите чат" case .contacts: return "Выберите контакт" case .calls: return "Выберите звонок" case .settings: return "Настройки" } } private var selectedConversation: Conversation? { guard let selectedJID else { return nil } if mode == .calls { guard let message = model.callHistoryMessages.first(where: { $0.clientID == selectedJID }) else { return nil } return conversations.first(where: { $0.jid == message.conversationID }) } return conversations.first(where: { $0.jid == selectedJID }) } private var defaultConversation: Conversation? { mode == .chats ? sortedChats.first : nil } // MARK: Filtering and sorting private var sortedChats: [Conversation] { conversations.sorted(by: conversationSort) } private var filteredChats: [Conversation] { searchText.isEmpty ? sortedChats : sortedChats.filter(matchesChat) } private func matchesChat(_ conversation: Conversation) -> Bool { conversation.displayName.localizedCaseInsensitiveContains( searchText ) || conversation.jid.localizedCaseInsensitiveContains(searchText) } private func conversationSort(_ lhs: Conversation, _ rhs: Conversation) -> Bool { if lhs.isPinned != rhs.isPinned { return lhs.isPinned } if lhs.lastActivity != rhs.lastActivity { return lhs.lastActivity > rhs.lastActivity } return lhs.displayName.localizedCaseInsensitiveCompare( rhs.displayName ) == .orderedAscending } private var filteredRoster: [Conversation] { conversations .filter { !$0.isGroup && model.rosterContactJIDs.contains($0.jid) } .filter { searchText.isEmpty || matchesChat($0) } .sorted(by: contactSort) } private var filteredGroups: [Conversation] { conversations .filter { $0.isGroup } .filter { searchText.isEmpty || matchesChat($0) } .sorted(by: contactSort) } private func contactSort(_ lhs: Conversation, _ rhs: Conversation) -> Bool { lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) == .orderedAscending } private var filteredCalls: [ChatMessage] { guard !searchText.isEmpty else { return model.callHistoryMessages } return model.callHistoryMessages.filter { message in message.conversationID.localizedCaseInsensitiveContains( searchText ) || (conversations.first(where: { $0.jid == message.conversationID })?.displayName .localizedCaseInsensitiveContains(searchText) ?? false) } } // MARK: Selection private func switchMode(_ newMode: Mode) { mode = newMode selectedJID = nil searchText = "" if newMode == .chats { selectedJID = sortedChats.first?.jid } } private func ensureSelection() { if mode == .chats, selectedJID == nil { selectedJID = sortedChats.first?.jid } } } #endif