diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c3372a42f..e77d09edf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,7 @@ on: jobs: build: name: Build and Test project - runs-on: self-hosted + runs-on: [ self-hosted, iOS ] steps: - name: Cancel Previous Runs diff --git a/.package.resolved b/.package.resolved index aff26dd01..3a0482a0e 100644 --- a/.package.resolved +++ b/.package.resolved @@ -134,6 +134,15 @@ "version" : "7.5.2" } }, + { + "identity" : "navigationbackport", + "kind" : "remoteSourceControl", + "location" : "https://github.com/johnpatrickmorgan/NavigationBackport", + "state" : { + "revision" : "5096dda355148dd40162810e7f56292ce0a2b09b", + "version" : "0.7.5" + } + }, { "identity" : "nuke", "kind" : "remoteSourceControl", @@ -143,6 +152,15 @@ "version" : "12.1.0" } }, + { + "identity" : "popovers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/aheze/Popovers", + "state" : { + "revision" : "de44c4dd7271ec6413fe350f7efadb14e5e18dce", + "version" : "1.3.2" + } + }, { "identity" : "realm-core", "kind" : "remoteSourceControl", @@ -166,8 +184,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/getsentry/sentry-cocoa", "state" : { - "revision" : "ac224c437a3070ffe34460137ac8761eddaf2852", - "version" : "8.3.3" + "revision" : "92a6472efc750a4e18bdee21c204942ab0bc4dcd", + "version" : "8.4.0" } }, { diff --git a/Mail/Components/RecipientAutocompletionCell.swift b/Mail/Components/RecipientCell.swift similarity index 86% rename from Mail/Components/RecipientAutocompletionCell.swift rename to Mail/Components/RecipientCell.swift index ab8926bbd..fb637b42c 100644 --- a/Mail/Components/RecipientAutocompletionCell.swift +++ b/Mail/Components/RecipientCell.swift @@ -19,11 +19,11 @@ import MailCore import SwiftUI -struct RecipientAutocompletionCell: View { +struct RecipientCell: View { let recipient: Recipient var body: some View { - HStack { + HStack(spacing: 8) { AvatarView(avatarDisplayable: recipient, size: 40) .accessibilityHidden(true) @@ -38,9 +38,9 @@ struct RecipientAutocompletionCell: View { .textStyle(.bodySecondary) } } - Spacer() } .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) .accessibilityElement(children: .combine) .accessibilityAddTraits(.isButton) } @@ -48,7 +48,7 @@ struct RecipientAutocompletionCell: View { struct RecipientAutocompletionCell_Previews: PreviewProvider { static var previews: some View { - RecipientAutocompletionCell(recipient: PreviewHelper.sampleRecipient1) - RecipientAutocompletionCell(recipient: PreviewHelper.sampleRecipient3) + RecipientCell(recipient: PreviewHelper.sampleRecipient1) + RecipientCell(recipient: PreviewHelper.sampleRecipient3) } } diff --git a/Mail/Components/RecipientChip.swift b/Mail/Components/RecipientChip.swift new file mode 100644 index 000000000..3789554a0 --- /dev/null +++ b/Mail/Components/RecipientChip.swift @@ -0,0 +1,77 @@ +/* + Infomaniak Mail - iOS App + Copyright (C) 2022 Infomaniak Network SA + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +import InfomaniakCoreUI +import MailCore +import MailResources +import Popovers +import SwiftUI + +struct RecipientChip: View { + @Environment(\.window) private var window + @AppStorage(UserDefaults.shared.key(.accentColor)) private var accentColor = DefaultPreferences.accentColor + + let recipient: Recipient + let fieldType: ComposeViewFieldType + @FocusState var focusedField: ComposeViewFieldType? + let removeHandler: () -> Void + let switchFocusHandler: () -> Void + + var body: some View { + Templates.Menu { + $0.width = nil + $0.originAnchor = .topLeft + $0.popoverAnchor = .topLeft + } content: { + RecipientCell(recipient: recipient) + .padding(.vertical, 8) + .padding(.horizontal, 16) + .frame(maxWidth: min(304, 0.8 * (window?.screen.bounds.width ?? 304))) + + Templates.MenuButton(text: Text(MailResourcesStrings.Localizable.contactActionCopyEmailAddress), + image: MailResourcesAsset.duplicate.swiftUIImage) { + UIPasteboard.general.string = recipient.email + IKSnackBar.showSnackBar(message: MailResourcesStrings.Localizable.snackbarEmailCopiedToClipboard) + } + + Templates.MenuButton(text: Text(MailResourcesStrings.Localizable.actionDelete), + image: MailResourcesAsset.bin.swiftUIImage) { + removeHandler() + } + } label: { isSelected in + RecipientChipLabelView(recipient: recipient, removeHandler: removeAndFocus, switchFocusHandler: switchFocusHandler) + .fixedSize() + .opacity(isSelected ? 0.8 : 1) + } + } + + private func removeAndFocus() { + focusedField = fieldType + removeHandler() + } +} + +struct RecipientChip_Previews: PreviewProvider { + static var previews: some View { + RecipientChip(recipient: PreviewHelper.sampleRecipient1, fieldType: .to) { + /* Preview */ + } switchFocusHandler: { + /* Preview */ + } + } +} diff --git a/Mail/Components/RecipientChipLabel.swift b/Mail/Components/RecipientChipLabel.swift new file mode 100644 index 000000000..aae845c04 --- /dev/null +++ b/Mail/Components/RecipientChipLabel.swift @@ -0,0 +1,103 @@ +/* + Infomaniak Mail - iOS App + Copyright (C) 2022 Infomaniak Network SA + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +import Foundation +import MailCore +import SwiftUI +import UIKit + +struct RecipientChipLabelView: UIViewRepresentable { + let recipient: Recipient + let removeHandler: () -> Void + let switchFocusHandler: () -> Void + + func makeUIView(context: Context) -> RecipientChipLabel { + let label = RecipientChipLabel(recipient: recipient) + label.removeHandler = removeHandler + label.switchFocusHandler = switchFocusHandler + return label + } + + func updateUIView(_ uiLabel: RecipientChipLabel, context: Context) { + uiLabel.text = recipient.name.isEmpty ? recipient.email : recipient.name + } +} + +class RecipientChipLabel: UILabel, UIKeyInput { + var removeHandler: (() -> Void)? + var switchFocusHandler: (() -> Void)? + + override var intrinsicContentSize: CGSize { + var contentSize = super.intrinsicContentSize + contentSize.height += UIConstants.chipInsets.top + UIConstants.chipInsets.bottom + contentSize.width += UIConstants.chipInsets.left + UIConstants.chipInsets.right + return contentSize + } + + override var canBecomeFirstResponder: Bool { return true } + + var hasText = false + + init(recipient: Recipient) { + super.init(frame: .zero) + + text = recipient.name.isEmpty ? recipient.email : recipient.name + textAlignment = .center + numberOfLines = 1 + + font = .systemFont(ofSize: 16) + updateColors(isFirstResponder: false) + + layer.cornerRadius = intrinsicContentSize.height / 2 + layer.masksToBounds = true + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func drawText(in rect: CGRect) { + super.drawText(in: rect.inset(by: UIConstants.chipInsets)) + } + + override func becomeFirstResponder() -> Bool { + updateColors(isFirstResponder: true) + return super.becomeFirstResponder() + } + + override func resignFirstResponder() -> Bool { + updateColors(isFirstResponder: false) + return super.resignFirstResponder() + } + + func insertText(_ text: String) { + if text == "\t" { + switchFocusHandler?() + } + } + + func deleteBackward() { + removeHandler?() + } + + private func updateColors(isFirstResponder: Bool) { + textColor = isFirstResponder ? UserDefaults.shared.accentColor.secondary.color : .tintColor + backgroundColor = isFirstResponder ? .tintColor : UserDefaults.shared.accentColor.secondary.color + } +} diff --git a/Mail/Components/RecipientField.swift b/Mail/Components/RecipientField.swift index 1815874d8..581a6445a 100644 --- a/Mail/Components/RecipientField.swift +++ b/Mail/Components/RecipientField.swift @@ -25,31 +25,14 @@ import RealmSwift import SwiftUI import WrappingHStack -struct RecipientChip: View { - let recipient: Recipient - let removeButtonTapped: () -> Void - - @AppStorage(UserDefaults.shared.key(.accentColor)) private var accentColor = DefaultPreferences.accentColor - - var body: some View { - Button(action: removeButtonTapped) { - Text(recipient.name.isEmpty ? recipient.email : recipient.name) - .textStyle(.bodyAccent) - .padding(.vertical, 6) - .lineLimit(1) - } - .padding(.leading, 12) - .padding(.trailing, 12) - .background(Capsule().fill(accentColor.secondary.swiftUIColor)) - } -} - struct RecipientField: View { @Binding var recipients: RealmSwift.List @Binding var autocompletion: [Recipient] @Binding var unknownRecipientAutocompletion: String @Binding var addRecipientHandler: ((Recipient) -> Void)? + @FocusState var focusedField: ComposeViewFieldType? + let type: ComposeViewFieldType @State private var currentText = "" @@ -59,26 +42,18 @@ struct RecipientField: View { VStack { if !recipients.isEmpty { WrappingHStack(recipients.indices, spacing: .constant(8), lineSpacing: 8) { i in - RecipientChip(recipient: recipients[i]) { + RecipientChip(recipient: recipients[i], fieldType: type, focusedField: _focusedField) { remove(recipientAt: i) + } switchFocusHandler: { + switchFocus() } + .focused($focusedField, equals: .chip(type.hashValue, recipients[i])) } .alignmentGuide(.newMessageCellAlignment) { d in d[.top] + 21 } } - TextField("", text: $currentText) - .textContentType(.emailAddress) - .keyboardType(.emailAddress) - .autocapitalization(.none) - .disableAutocorrection(true) - .multilineTextAlignment(.leading) + + RecipientsTextFieldView(text: $currentText, onSubmit: submitTextField, onBackspace: handleBackspaceTextField) .focused($focusedField, equals: type) - .onSubmit { - guard let recipient = autocompletion.first else { return } - add(recipient: recipient) - focusedField = type - @InjectService var matomo: MatomoUtils - matomo.track(eventWithCategory: .newMessage, action: .input, name: "addNewRecipient") - } } .onChange(of: currentText) { _ in updateAutocompletion() @@ -95,12 +70,31 @@ struct RecipientField: View { } } + @MainActor private func submitTextField() { + guard let recipient = autocompletion.first else { + IKSnackBar.showSnackBar( + message: MailResourcesStrings.Localizable.addUnknownRecipientInvalidEmail, + anchor: keyboardHeight + ) + return + } + add(recipient: recipient) + @InjectService var matomo: MatomoUtils + matomo.track(eventWithCategory: .newMessage, action: .input, name: "addNewRecipient") + } + + private func handleBackspaceTextField(isTextEmpty: Bool) { + if let recipient = recipients.last, isTextEmpty { + focusedField = .chip(type.hashValue, recipient) + } + } + private func updateAutocompletion() { let trimmedCurrentText = currentText.trimmingCharacters(in: .whitespacesAndNewlines) let contactManager = AccountManager.instance.currentContactManager let autocompleteContacts = contactManager?.contacts(matching: trimmedCurrentText) ?? [] - var autocompleteRecipients = autocompleteContacts.map { Recipient(email: $0.email, name: $0.name) } + let autocompleteRecipients = autocompleteContacts.map { Recipient(email: $0.email, name: $0.name) } withAnimation { autocompletion = autocompleteRecipients.filter { !recipients.map(\.email).contains($0.email) } @@ -133,6 +127,16 @@ struct RecipientField: View { $recipients.remove(at: recipientAt) } } + + private func switchFocus() { + guard case let .chip(hash, recipient) = focusedField else { return } + + if recipient == recipients.last { + focusedField = type + } else if let recipientIndex = recipients.firstIndex(of: recipient) { + focusedField = .chip(hash, recipients[recipientIndex + 1]) + } + } } struct RecipientField_Previews: PreviewProvider { diff --git a/Mail/Views/New Message/AutocompletionView.swift b/Mail/Views/New Message/AutocompletionView.swift index 4fb834bee..4acecb730 100644 --- a/Mail/Views/New Message/AutocompletionView.swift +++ b/Mail/Views/New Message/AutocompletionView.swift @@ -33,7 +33,7 @@ struct AutocompletionView: View { Button { onSelect(recipient) } label: { - RecipientAutocompletionCell(recipient: recipient) + RecipientCell(recipient: recipient) } .padding(.horizontal, 8) diff --git a/Mail/Views/New Message/ComposeMessageView.swift b/Mail/Views/New Message/ComposeMessageView.swift index e5d613ca5..c6deb66c5 100644 --- a/Mail/Views/New Message/ComposeMessageView.swift +++ b/Mail/Views/New Message/ComposeMessageView.swift @@ -28,6 +28,7 @@ import SwiftUI enum ComposeViewFieldType: Hashable { case from, to, cc, bcc, subject + case chip(Int, Recipient) var title: String { switch self { @@ -41,6 +42,8 @@ enum ComposeViewFieldType: Hashable { return MailResourcesStrings.Localizable.bccTitle case .subject: return MailResourcesStrings.Localizable.subjectTitle + case .chip: + return "Recipient Chip" } } } @@ -219,9 +222,9 @@ struct ComposeMessageView: View { } .customAlert(isPresented: $alert.isShowing) { switch alert.state { - case .link(let handler): + case let .link(handler): AddLinkView(actionHandler: handler) - case .emptySubject(let handler): + case let .emptySubject(handler): EmptySubjectView(actionHandler: handler) case .none: EmptyView() diff --git a/Mail/Views/New Message/NewMessageCell.swift b/Mail/Views/New Message/NewMessageCell.swift index c64b07b01..fdd8f1520 100644 --- a/Mail/Views/New Message/NewMessageCell.swift +++ b/Mail/Views/New Message/NewMessageCell.swift @@ -77,19 +77,21 @@ struct NewMessageCell: View where Content: View { } } -struct RecipientCellView_Previews: PreviewProvider { +struct NewMessageCell_Previews: PreviewProvider { static var previews: some View { - NewMessageCell(type: .to, - showCc: .constant(false)) { - RecipientField(recipients: .constant([PreviewHelper.sampleRecipient1].toRealmList()), - autocompletion: .constant([]), - unknownRecipientAutocompletion: .constant(""), - addRecipientHandler: .constant { _ in /* Preview */ }, - focusedField: .init(), - type: .to) - } - NewMessageCell(type: .subject) { - TextField("", text: .constant("")) + VStack { + NewMessageCell(type: .to, + showCc: .constant(false)) { + RecipientField(recipients: .constant([PreviewHelper.sampleRecipient1].toRealmList()), + autocompletion: .constant([]), + unknownRecipientAutocompletion: .constant(""), + addRecipientHandler: .constant { _ in /* Preview */ }, + focusedField: .init(), + type: .to) + } + NewMessageCell(type: .subject) { + TextField("", text: .constant("")) + } } } } diff --git a/Mail/Views/New Message/RecipientsTextField.swift b/Mail/Views/New Message/RecipientsTextField.swift new file mode 100644 index 000000000..b32f52f02 --- /dev/null +++ b/Mail/Views/New Message/RecipientsTextField.swift @@ -0,0 +1,95 @@ +/* + Infomaniak Mail - iOS App + Copyright (C) 2022 Infomaniak Network SA + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +import SwiftUI +import UIKit + +struct RecipientsTextFieldView: UIViewRepresentable { + @Binding var text: String + + let onSubmit: () -> Void + let onBackspace: (Bool) -> Void + + func makeUIView(context: Context) -> UITextField { + let textField = RecipientsTextField() + textField.delegate = context.coordinator + textField.addTarget(context.coordinator, action: #selector(context.coordinator.textDidChanged(_:)), for: .editingChanged) + textField.onBackspace = onBackspace + return textField + } + + func updateUIView(_ textField: UITextField, context: Context) { + guard textField.text != text else { return } + textField.text = text + } + + func makeCoordinator() -> Coordinator { + return Coordinator(self) + } + + class Coordinator: NSObject, UITextFieldDelegate { + let parent: RecipientsTextFieldView + + init(_ parent: RecipientsTextFieldView) { + self.parent = parent + } + + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + guard textField.text?.isEmpty == false else { + textField.resignFirstResponder() + return true + } + + parent.onSubmit() + return true + } + + @objc func textDidChanged(_ textField: UITextField) { + parent.text = textField.text ?? "" + } + } +} + +/* + * We need to create our own UITextField to benefit from the `deleteBackward()` function + */ +class RecipientsTextField: UITextField { + var onBackspace: ((Bool) -> Void)? + + override init(frame: CGRect) { + super.init(frame: frame) + setUpView() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setUpView() + } + + private func setUpView() { + textContentType = .emailAddress + keyboardType = .emailAddress + autocapitalizationType = .none + autocorrectionType = .no + } + + override func deleteBackward() { + onBackspace?(text?.isEmpty == true) + super.deleteBackward() + } +} diff --git a/Mail/Views/Search/SearchContactsSectionView.swift b/Mail/Views/Search/SearchContactsSectionView.swift index 7b1e0a210..86488a46f 100644 --- a/Mail/Views/Search/SearchContactsSectionView.swift +++ b/Mail/Views/Search/SearchContactsSectionView.swift @@ -28,7 +28,7 @@ struct SearchContactsSectionView: View { var body: some View { Section { ForEach(viewModel.contacts) { contact in - RecipientAutocompletionCell(recipient: contact) + RecipientCell(recipient: contact) .onTapGesture { viewModel.matomo.track(eventWithCategory: .search, name: "selectContact") Constants.globallyResignFirstResponder() diff --git a/Mail/Views/SplitView.swift b/Mail/Views/SplitView.swift index f2f7277cf..72ed51fd2 100644 --- a/Mail/Views/SplitView.swift +++ b/Mail/Views/SplitView.swift @@ -21,9 +21,21 @@ import InfomaniakCore import Introspect import MailCore import MailResources +import NavigationBackport import RealmSwift import SwiftUI +struct MailNavigationPathKey: EnvironmentKey { + static var defaultValue: Binding<[Thread]>? +} + +extension EnvironmentValues { + var mailNavigationPath: Binding<[Thread]>? { + get { self[MailNavigationPathKey.self] } + set { self[MailNavigationPathKey.self] = newValue } + } +} + class GlobalBottomSheet: DisplayedFloatingPanelState { enum State { case getMoreStorage @@ -62,6 +74,7 @@ struct SplitView: View { @StateObject private var alert = GlobalAlert() @StateObject private var splitViewManager: SplitViewManager + @State private var path = [Thread]() var isCompact: Bool { UIConstants.isCompact(horizontalSizeClass: horizontalSizeClass, verticalSizeClass: verticalSizeClass) @@ -77,9 +90,12 @@ struct SplitView: View { Group { if isCompact { ZStack { - NavigationView { + NBNavigationStack(path: $path) { ThreadListManagerView(isCompact: isCompact) .accessibilityHidden(navigationDrawerController.isOpen) + .nbNavigationDestination(for: Thread.self) { thread in + ThreadView(thread: thread) + } } .navigationViewStyle(.stack) @@ -95,7 +111,11 @@ struct SplitView: View { ThreadListManagerView(isCompact: isCompact) - EmptyStateView.emptyThread(from: splitViewManager.selectedFolder) + if let thread = path.last { + ThreadView(thread: thread) + } else { + EmptyStateView.emptyThread(from: splitViewManager.selectedFolder) + } } } } @@ -140,6 +160,7 @@ struct SplitView: View { EmptyView() } } + .environment(\.mailNavigationPath, $path) .environment(\.realmConfiguration, mailboxManager.realmConfiguration) .environmentObject(mailboxManager) .environmentObject(splitViewManager) diff --git a/Mail/Views/Thread List/ThreadListCell.swift b/Mail/Views/Thread List/ThreadListCell.swift index a95996504..e791ba606 100644 --- a/Mail/Views/Thread List/ThreadListCell.swift +++ b/Mail/Views/Thread List/ThreadListCell.swift @@ -25,6 +25,7 @@ import SwiftUI struct ThreadListCell: View { @EnvironmentObject var splitViewManager: SplitViewManager + @Environment(\.mailNavigationPath) private var path let thread: Thread @@ -34,41 +35,26 @@ struct ThreadListCell: View { let threadDensity: ThreadDensity let isSelected: Bool + let isMultiSelected: Bool @Binding var editedMessageDraft: Draft? @State private var shouldNavigateToThreadList = false private var selectionType: SelectionBackgroundKind { - if isSelected { - return .multiple - } else if !multipleSelectionViewModel.isEnabled && viewModel.selectedThread?.uid == thread.uid { - return .single + if multipleSelectionViewModel.isEnabled { + return isMultiSelected ? .multiple : .none } - return .none - } - - private var selectedThreadBackground: Bool { - return !multipleSelectionViewModel.isEnabled && (viewModel.selectedThread?.uid == thread.uid) + return isSelected ? .single : .none } var body: some View { - ZStack { - if !thread.shouldPresentAsDraft { - NavigationLink(destination: ThreadView(thread: thread, - onDismiss: { viewModel.selectedThread = nil }), - isActive: $shouldNavigateToThreadList) { EmptyView() } - .opacity(0) - .disabled(multipleSelectionViewModel.isEnabled) - } - - ThreadCell( - thread: thread, - density: threadDensity, - isMultipleSelectionEnabled: multipleSelectionViewModel.isEnabled, - isSelected: isSelected - ) - } + ThreadCell( + thread: thread, + density: threadDensity, + isMultipleSelectionEnabled: multipleSelectionViewModel.isEnabled, + isSelected: isMultiSelected + ) .background(SelectionBackground(selectionType: selectionType, paddingLeading: 4)) .onTapGesture { didTapCell() } .onLongPressGesture(minimumDuration: 0.3) { didLongPressCell() } @@ -76,11 +62,6 @@ struct ThreadListCell: View { .listRowInsets(.init(top: 0, leading: 0, bottom: 0, trailing: 0)) .listRowSeparator(.hidden) .listRowBackground(MailResourcesAsset.backgroundColor.swiftUIColor) - .onChange(of: viewModel.selectedThread) { newThread in - if newThread?.uid == thread.uid { - shouldNavigateToThreadList = true - } - } } private func didTapCell() { @@ -101,7 +82,6 @@ struct ThreadListCell: View { splitViewManager.splitViewController?.hide(.supplementary) } viewModel.selectedThread = thread - shouldNavigateToThreadList = true } } } @@ -132,6 +112,7 @@ struct ThreadListCell_Previews: PreviewProvider { multipleSelectionViewModel: ThreadListMultipleSelectionViewModel(mailboxManager: PreviewHelper.sampleMailboxManager), threadDensity: .large, isSelected: false, + isMultiSelected: false, editedMessageDraft: .constant(nil) ) } diff --git a/Mail/Views/Thread List/ThreadListView.swift b/Mail/Views/Thread List/ThreadListView.swift index f123a0940..afba54167 100644 --- a/Mail/Views/Thread List/ThreadListView.swift +++ b/Mail/Views/Thread List/ThreadListView.swift @@ -53,6 +53,7 @@ struct ThreadListView: View { @EnvironmentObject var splitViewManager: SplitViewManager @EnvironmentObject var globalBottomSheet: GlobalBottomSheet + @Environment(\.mailNavigationPath) private var path @AppStorage(UserDefaults.shared.key(.threadDensity)) private var threadDensity = DefaultPreferences.threadDensity @AppStorage(UserDefaults.shared.key(.accentColor)) private var accentColor = DefaultPreferences.accentColor @@ -142,7 +143,8 @@ struct ThreadListView: View { viewModel: viewModel, multipleSelectionViewModel: multipleSelectionViewModel, threadDensity: threadDensity, - isSelected: multipleSelectionViewModel.selectedItems + isSelected: viewModel.selectedThread?.uid == thread.uid, + isMultiSelected: multipleSelectionViewModel.selectedItems .contains { $0.id == thread.id }, editedMessageDraft: $editedMessageDraft) .id(thread.id) @@ -200,12 +202,11 @@ struct ThreadListView: View { flushAlert: $flushAlert, bottomSheet: bottomSheet, viewModel: viewModel, - multipleSelectionViewModel: multipleSelectionViewModel, - selectAll: { - withAnimation(.default.speed(2)) { - multipleSelectionViewModel.selectAll(threads: viewModel.filteredThreads) - } - })) + multipleSelectionViewModel: multipleSelectionViewModel) { + withAnimation(.default.speed(2)) { + multipleSelectionViewModel.selectAll(threads: viewModel.filteredThreads) + } + }) .floatingActionButton(isEnabled: !multipleSelectionViewModel.isEnabled, icon: MailResourcesAsset.pencilPlain, title: MailResourcesStrings.Localizable.buttonNewMessage) { @@ -230,6 +231,13 @@ struct ThreadListView: View { .onChange(of: splitViewManager.selectedFolder) { newFolder in changeFolder(newFolder: newFolder) } + .onChange(of: viewModel.selectedThread) { newThread in + if let newThread { + path?.wrappedValue = [newThread] + } else { + path?.wrappedValue = [] + } + } .onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in updateFetchingTask() } @@ -243,7 +251,7 @@ struct ThreadListView: View { ComposeMessageView.newMessage(mailboxManager: viewModel.mailboxManager) } .sheet(isPresented: $moveSheet.isShowing) { - if case .move(let folderId, let handler) = moveSheet.state { + if case let .move(folderId, handler) = moveSheet.state { MoveEmailView.sheetView(mailboxManager: viewModel.mailboxManager, from: folderId, moveHandler: handler) } } diff --git a/Mail/Views/Thread List/ThreadListViewModel.swift b/Mail/Views/Thread List/ThreadListViewModel.swift index aeeed7908..6a5314b10 100644 --- a/Mail/Views/Thread List/ThreadListViewModel.swift +++ b/Mail/Views/Thread List/ThreadListViewModel.swift @@ -93,13 +93,7 @@ class DateSection: Identifiable { @Published var sections = [DateSection]() @Published var selectedThread: Thread? { didSet { - guard !isCompact, !filteredThreads.isEmpty else { return } - if selectedThread == nil, let index = selectedThreadIndex { - let realIndex = min(index, filteredThreads.count - 1) - selectedThread = filteredThreads[realIndex] - } else if let thread = selectedThread, let index = filteredThreads.firstIndex(where: { $0.uid == thread.uid }) { - selectedThreadIndex = index - } + selectedThreadIndex = filteredThreads.firstIndex { $0.uid == selectedThread?.uid } } } @@ -240,6 +234,7 @@ class DateSection: Identifiable { guard let newSections = self?.sortThreadsIntoSections(threads: filteredThreads) else { return } DispatchQueue.main.sync { + self?.nextThreadIfNeeded(from: filteredThreads) self?.filteredThreads = filteredThreads if self?.filter != .all && filteredThreads.count == 1 && self?.filter.accepts(thread: filteredThreads[0]) != true { @@ -265,6 +260,14 @@ class DateSection: Identifiable { } } + func nextThreadIfNeeded(from threads: [Thread]) { + guard !isCompact, + !threads.contains(where: { $0.uid == selectedThread?.uid }), + let lastIndex = selectedThreadIndex else { return } + let validIndex = min(lastIndex, threads.count - 1) + selectedThread = threads[validIndex] + } + private func sortThreadsIntoSections(threads: [Thread]) -> [DateSection]? { var newSections = [DateSection]() diff --git a/Mail/Views/Thread/ThreadView.swift b/Mail/Views/Thread/ThreadView.swift index c271c7e72..4c35bdadf 100644 --- a/Mail/Views/Thread/ThreadView.swift +++ b/Mail/Views/Thread/ThreadView.swift @@ -41,10 +41,10 @@ class MessageBottomSheet: DisplayedFloatingPanelState struct ThreadView: View { @EnvironmentObject private var splitViewManager: SplitViewManager + @Environment(\.mailNavigationPath) private var path @EnvironmentObject private var mailboxManager: MailboxManager @ObservedRealmObject var thread: Thread - var onDismiss: (() -> Void)? @State private var headerHeight: CGFloat = 0 @State private var displayNavigationTitle = false @@ -56,9 +56,14 @@ struct ThreadView: View { @State private var showEmptyView = false @EnvironmentObject var globalAlert: GlobalAlert - @Environment(\.verticalSizeClass) var sizeClass + @Environment(\.horizontalSizeClass) private var sizeClass + @Environment(\.verticalSizeClass) private var verticalSizeClass @Environment(\.dismiss) var dismiss + var isCompact: Bool { + sizeClass == .compact || verticalSizeClass == .compact + } + @LazyInjectService private var matomo: MatomoUtils private let toolbarActions: [Action] = [.reply, .forward, .archive, .delete] @@ -179,19 +184,14 @@ struct ThreadView: View { } }*/ .onChange(of: thread.messages) { newMessagesList in - if newMessagesList.isEmpty { - showEmptyView = true - onDismiss?() - dismiss() - } - if thread.messageInFolderCount == 0 { - onDismiss?() - dismiss() + if newMessagesList.isEmpty || thread.messageInFolderCount == 0 { + if isCompact { + dismiss() // For iPhone + } else { + path?.wrappedValue = [] // For iPad + } } } - .emptyState(isEmpty: showEmptyView) { - EmptyStateView.emptyThread(from: splitViewManager.selectedFolder) - } .matomoView(view: [MatomoUtils.View.threadView.displayName, "Main"]) } diff --git a/MailCore/UI/UIConstants.swift b/MailCore/UI/UIConstants.swift index 2a4a9ae5f..038036d45 100644 --- a/MailCore/UI/UIConstants.swift +++ b/MailCore/UI/UIConstants.swift @@ -98,6 +98,8 @@ public enum UIConstants { public static let componentsMaxWidth: CGFloat = 496 + public static let chipInsets = UIEdgeInsets(top: 4, left: 8, bottom: 4, right: 8) + public static func isCompact(horizontalSizeClass: UserInterfaceSizeClass?, verticalSizeClass: UserInterfaceSizeClass?) -> Bool { return horizontalSizeClass == .compact || verticalSizeClass == .compact } diff --git a/MailCore/Utils/MailTextStyle.swift b/MailCore/Utils/MailTextStyle.swift index 51f886cc5..d5036a39e 100644 --- a/MailCore/Utils/MailTextStyle.swift +++ b/MailCore/Utils/MailTextStyle.swift @@ -105,6 +105,11 @@ public struct MailTextStyle { color: .accentColor ) + public static let bodyAccentSecondary = MailTextStyle( + font: .system(size: 16), + color: UserDefaults.shared.accentColor.secondary + ) + public static let bodySecondary = MailTextStyle( font: .system(size: 16), color: MailResourcesAsset.textSecondaryColor diff --git a/Project.swift b/Project.swift index 7bdabeadc..bd2b85d0e 100644 --- a/Project.swift +++ b/Project.swift @@ -48,6 +48,8 @@ let project = Project(name: "Mail", .package(url: "https://github.com/kean/Nuke", .upToNextMajor(from: "12.0.0")), .package(url: "https://github.com/airbnb/lottie-ios", .exact("3.5.0")), .package(url: "https://github.com/scinfu/SwiftSoup", .upToNextMajor(from: "2.5.3")), + .package(url: "https://github.com/johnpatrickmorgan/NavigationBackport", .upToNextMajor(from: "0.7.2")), + .package(url: "https://github.com/aheze/Popovers", .upToNextMajor(from: "1.3.2")), .package(url: "https://github.com/shaps80/SwiftUIBackports", .upToNextMajor(from: "1.15.1")) ], targets: [ @@ -79,6 +81,8 @@ let project = Project(name: "Mail", .package(product: "WrappingHStack"), .package(product: "FloatingPanel"), .package(product: "Lottie"), + .package(product: "NavigationBackport"), + .package(product: "Popovers"), .package(product: "SwiftUIBackports") ], settings: .settings(base: baseSettings),