diff --git a/AGENTS.md b/AGENTS.md index 278359a..70ad0b6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,8 +13,11 @@ and the Martin / MartinOMEMO libraries. The Xcode project is generated from (`ChatMessage`, `Conversation`). - `UI/` — SwiftUI views; chat/contact lists read SwiftData through `@Query` (`MainTabView`, `ChatView`, `ForwardMessageView`); the main - screen is a Telegram-style bottom tab bar (Контакты/Звонки/Чаты/ - Настройки). + screen is Telegram-style: an iOS native `TabView` tab bar + (Контакты/Звонки/Чаты/Настройки) with inline search fields above + the lists, and a + macOS Telegram Desktop-style split view (`MainSplitView`: sidebar + with menu/search/list plus a chat detail pane). - `XMPP/` — `XMPPService` (MAM/OMEMO/MUC), `LumaCallEngine`, OMEMO store, `SASLprep` (RFC 4013), SCRAM-SHA-512 mechanism, SASL failure observer/messages. diff --git a/Luma.xcodeproj/project.pbxproj b/Luma.xcodeproj/project.pbxproj index 7617a93..f5009d5 100644 --- a/Luma.xcodeproj/project.pbxproj +++ b/Luma.xcodeproj/project.pbxproj @@ -160,6 +160,7 @@ 9AB2A519206C5016B0D8C6DE /* ServerInformation.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6A5C32B4DACD56DA733A0DB /* ServerInformation.swift */; }; 9B53D50C5EDE4AD507C3F5B6 /* AppAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 184902980B7EE4B8DDEA907D /* AppAssets.xcassets */; }; 9CA60ADF5AEFB8CE70B25EE8 /* EncryptionDevicesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 185E3C916B72D36733DE15AF /* EncryptionDevicesView.swift */; }; + 9E36AF2F40D4EC3AF5CE1680 /* LayoutCheckUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6597326A509F73A2993A6B80 /* LayoutCheckUITests.swift */; }; 9E71E1DC6BAA1E9CA32C5355 /* ArchiveMessageBatchPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDA85EA65BC449733C67084 /* ArchiveMessageBatchPolicy.swift */; }; 9E98F99F3FBFC9DAB99A3650 /* NotificationPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6C7D0F207377DE99146A0D2 /* NotificationPolicyTests.swift */; }; 9F288ACC92CD62A2E0CCE157 /* SASLprep.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2446F6A1D00ADAD261D4CCD /* SASLprep.swift */; }; @@ -355,6 +356,7 @@ 61380B78824FDB98A933C7E9 /* ArchiveSyncRecoveryPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveSyncRecoveryPolicyTests.swift; sourceTree = ""; }; 6317FAA29F22A3EA8450208F /* LumaTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = LumaTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 63E4D099BDCB2311ADFE39E6 /* CallHistorySync.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallHistorySync.swift; sourceTree = ""; }; + 6597326A509F73A2993A6B80 /* LayoutCheckUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LayoutCheckUITests.swift; sourceTree = ""; }; 6773F87EE60F91BBDCA4B1F5 /* GroupConversationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupConversationTests.swift; sourceTree = ""; }; 69F4FFBFD65C218FF6505844 /* SaslFailureMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaslFailureMessage.swift; sourceTree = ""; }; 6DCB924748780145A707D890 /* MediaViewerDismissGestureTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaViewerDismissGestureTests.swift; sourceTree = ""; }; @@ -574,6 +576,7 @@ 307BDB7CACD11611A6CCDC4E /* UITests */ = { isa = PBXGroup; children = ( + 6597326A509F73A2993A6B80 /* LayoutCheckUITests.swift */, 0B5359E7A89ABF58B78CE036 /* TimelineUITests.swift */, ); path = UITests; @@ -1191,6 +1194,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 9E36AF2F40D4EC3AF5CE1680 /* LayoutCheckUITests.swift in Sources */, 48812531C1B7DB7752F06DEF /* TimelineUITests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Scripts/verify.sh b/Scripts/verify.sh index e866a1a..d1d9c67 100755 --- a/Scripts/verify.sh +++ b/Scripts/verify.sh @@ -286,6 +286,22 @@ grep -q '@Query' Sources/Shared/UI/MainTabView.swift || { echo "Chat list must be SwiftData @Query-driven" exit 1 } +grep -q 'NavigationSplitView' Sources/Shared/UI/MainTabView.swift || { + echo "macOS must keep the Telegram Desktop-style split view" + exit 1 +} +grep -q 'SearchField' Sources/Shared/UI/MainTabView.swift || { + echo "Search fields must stay inline above the lists" + exit 1 +} +grep -q 'TabView(selection:' Sources/Shared/UI/MainTabView.swift || { + echo "The iOS main screen must use the native TabView tab bar" + exit 1 +} +grep -q 'toolbar(.hidden, for: .tabBar)' Sources/Shared/UI/ChatView.swift || { + echo "The tab bar must be hidden inside a pushed chat" + exit 1 +} grep -q '@Query' Sources/Shared/UI/ChatView.swift || { echo "Chat timeline must be SwiftData @Query-driven" exit 1 diff --git a/Sources/Shared/UI/ChatView.swift b/Sources/Shared/UI/ChatView.swift index 7c6f0bd..5309195 100644 --- a/Sources/Shared/UI/ChatView.swift +++ b/Sources/Shared/UI/ChatView.swift @@ -226,6 +226,9 @@ struct ChatView: View { replyThreadSelection == nil ? .visible : .hidden, for: .navigationBar ) + // A pushed conversation takes the whole screen; the tab bar + // comes back when the user pops back to the list. + .toolbar(.hidden, for: .tabBar) #endif .fileImporter( isPresented: $showingFileImporter, diff --git a/Sources/Shared/UI/MainTabView.swift b/Sources/Shared/UI/MainTabView.swift index 86d1256..0ab2646 100644 --- a/Sources/Shared/UI/MainTabView.swift +++ b/Sources/Shared/UI/MainTabView.swift @@ -2,10 +2,67 @@ import Foundation import SwiftData import SwiftUI -/// Telegram-style main screen: a custom bottom tab bar (Контакты / Звонки / -/// Чаты / Настройки) shared by iOS and macOS, with a search field above the -/// chat and contact lists. +/// 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) + .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 @@ -46,56 +103,39 @@ struct MainTabView: View { @State private var selectedTab: Tab = .chats var body: some View { - VStack(spacing: 0) { - Group { - switch selectedTab { - case .contacts: - ContactsTab(model: model) - case .calls: - CallsTab(model: model) - case .chats: - ChatsTab(model: model) - case .settings: - NavigationStack { - SettingsView(model: model, presentedAsTab: true) + // 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) } - .frame(maxWidth: .infinity, maxHeight: .infinity) - - Divider() - tabBar } - .ignoresSafeArea(.keyboard, edges: .bottom) } - private var tabBar: some View { - HStack(spacing: 0) { - ForEach(Tab.allCases) { tab in - Button { - selectedTab = tab - } label: { - VStack(spacing: 3) { - Image(systemName: selectedTab == tab ? tab.selectedIcon : tab.icon) - .font(.system(size: 21, weight: .medium)) - Text(tab.title) - .font(.system(size: 10, weight: selectedTab == tab ? .semibold : .regular)) - } - .foregroundStyle(selectedTab == tab ? Color.accentColor : Color.secondary) - .frame(maxWidth: .infinity) - .padding(.vertical, 6) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .accessibilityLabel(tab.title) - .help(tab.title) + @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) } } - .padding(.horizontal, 8) - .padding(.bottom, 2) - .background(.bar) } } +#endif // MARK: - Chats @@ -107,9 +147,9 @@ private struct ChatsTab: View { @State private var pendingGroupDeletion: Conversation? /// 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`). + /// `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), @@ -120,16 +160,11 @@ private struct ChatsTab: View { var body: some View { NavigationStack { VStack(spacing: 0) { + SearchField(text: $searchText, prompt: "Поиск чатов") ConnectionBanner(model: model) -#if os(macOS) - searchField -#endif chatList } .navigationTitle("Чаты") -#if os(iOS) - .searchable(text: $searchText, prompt: "Поиск чатов") -#endif .toolbar { ToolbarItemGroup(placement: .primaryAction) { Button { showingNewGroup = true } label: { @@ -217,15 +252,6 @@ private struct ChatsTab: View { } } -#if os(macOS) - private var searchField: some View { - TextField("Поиск чатов", text: $searchText) - .textFieldStyle(.roundedBorder) - .padding(.horizontal, 12) - .padding(.vertical, 8) - } -#endif - 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 } @@ -255,19 +281,11 @@ private struct ContactsTab: View { var body: some View { NavigationStack { VStack(spacing: 0) { + SearchField(text: $searchText, prompt: "Поиск контактов") ConnectionBanner(model: model) -#if os(macOS) - TextField("Поиск контактов", text: $searchText) - .textFieldStyle(.roundedBorder) - .padding(.horizontal, 12) - .padding(.vertical, 8) -#endif contactsList } .navigationTitle("Контакты") -#if os(iOS) - .searchable(text: $searchText, prompt: "Поиск контактов") -#endif } } @@ -482,3 +500,353 @@ private struct ContactRow: View { } } +// MARK: - macOS: Telegram Desktop-style split view + +#if os(macOS) +private struct MainSplitView: View { + private enum Mode: String, CaseIterable, Identifiable { + case chats + case contacts + case calls + 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" + case .contacts: return "person.2" + case .calls: return "phone" + case .settings: return "gearshape" + } + } + + 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) + } detail: { + detailPane + } + .frame(minWidth: 840, minHeight: 560) + .toolbar { + 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("Новый личный чат") + } + } + } + .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) { + HStack(spacing: 8) { + Menu { + ForEach(Mode.allCases) { item in + Button { + switchMode(item) + } label: { + Label(item.title, systemImage: item.icon) + } + } + } label: { + Image(systemName: "line.3.horizontal") + .font(.system(size: 13, weight: .semibold)) + .frame(width: 28, height: 28) + .contentShape(Rectangle()) + } + .menuStyle(.borderlessButton) + .fixedSize() + .help("Меню") + .accessibilityLabel("Меню") + + if mode != .settings { + SearchField(text: $searchText, prompt: mode.searchPrompt) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + + if mode != .settings { + ConnectionBanner(model: model) + sidebarList + } + } + } + + private var sidebarList: some View { + List(selection: $selectedJID) { + switch mode { + case .chats: + chatRows + case .contacts: + contactRows + case .calls: + callRows + case .settings: + 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("Люди из roster") { + 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 { + NavigationStack { + SettingsView(model: model, presentedAsTab: true) + } + } 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 diff --git a/UITests/LayoutCheckUITests.swift b/UITests/LayoutCheckUITests.swift new file mode 100644 index 0000000..a4d40a0 --- /dev/null +++ b/UITests/LayoutCheckUITests.swift @@ -0,0 +1,89 @@ +import XCTest + +/// Layout regression checks: the inline search fields must live at the top +/// of the Chats and Contacts tabs, above the lists (they used to be pushed +/// to the bottom when implemented via `.searchable`). +final class LayoutCheckUITests: XCTestCase { + override func setUpWithError() throws { + continueAfterFailure = false + } + + private func launchApp() -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments = ["-luma-ui-test-chat"] + app.launch() + return app + } + + func testChatsAndContactsSearchFieldsAreAtTop() throws { + let app = launchApp() + dismissAnyAlert(app) + + // Keep the clean screen on display long enough for external + // `simctl io screenshot` captures of the tab bar. + Thread.sleep(forTimeInterval: 12) + + let chatSearch = app.textFields["Поиск чатов"] + XCTAssertTrue(chatSearch.waitForExistence(timeout: 30)) + addScreenshot(named: "chats-search-top", app: app) + + let chatRow = app.staticTexts["uitest-peer"] + XCTAssertTrue(chatRow.waitForExistence(timeout: 15)) + XCTAssertLessThan( + chatSearch.frame.midY, + chatRow.frame.midY, + "The chats search field must sit above the chat list" + ) + + app.buttons["Контакты"].tap() + + let contactSearch = app.textFields["Поиск контактов"] + XCTAssertTrue(contactSearch.waitForExistence(timeout: 15)) + XCTAssertLessThan( + contactSearch.frame.minY, + app.frame.height * 0.4, + "The contacts search field must sit near the top of the screen" + ) + addScreenshot(named: "contacts-search-top", app: app) + } + + func testTabBarHidesInsideChat() throws { + let app = launchApp() + dismissAnyAlert(app) + + let chatRow = app.staticTexts["uitest-peer"] + XCTAssertTrue(chatRow.waitForExistence(timeout: 30)) + chatRow.tap() + + let timeline = app.descendants(matching: .any).matching(identifier: "chat-timeline").firstMatch + XCTAssertTrue(timeline.waitForExistence(timeout: 30)) + + // A pushed conversation takes the whole screen: no tab bar. + let tabBar = app.tabBars.firstMatch + XCTAssertFalse(tabBar.exists, "The tab bar must be hidden inside a chat") + + // Popping back to the list restores the tab bar. + app.navigationBars.buttons.firstMatch.tap() + XCTAssertTrue(tabBar.waitForExistence(timeout: 15)) + } + + private func addScreenshot(named name: String, app: XCUIApplication) { + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + } + + /// The unsigned debug build can present a system "missing rights" alert + /// on the simulator; dismiss it so it does not cover the UI. + private func dismissAnyAlert(_ app: XCUIApplication) { + let hosts = [app, XCUIApplication(bundleIdentifier: "com.apple.springboard")] + for host in hosts { + let ok = host.alerts.buttons["OK"] + if ok.waitForExistence(timeout: 8) { + ok.tap() + return + } + } + } +}