Merge remote-tracking branch 'origin/main' into HEAD

This commit is contained in:
Jacek Krasiukianis
2025-10-06 14:25:22 +02:00
225 changed files with 4068 additions and 2113 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
{
"project": "apple-mail-new",
"locale": "5f46109dcb770a428c3cb55dbbed93e75be1363f"
"locale": "3d23586db025238c56593e2c1bae1bff693337fb"
}
@@ -19,5 +19,5 @@ import InboxCore
import proton_app_uniffi
extension ApiConfig {
static let current = Self.init(userAgent: UserAgent.value(appName: "ProtonMail"), envId: .current)
static let current = Self.init(envId: .current)
}
@@ -27,7 +27,7 @@ protocol SenderImageDataSource {
}
/// This is the information required by the Rust SDK to retrieve a sender's avatar image
struct SenderImageDataParameters: Equatable {
struct SenderImageDataParameters: Hashable {
let address: String
let bimiSelector: String?
let displaySenderImage: Bool
@@ -20,11 +20,14 @@ import InboxDesignSystem
enum InternalAction {
case more
case editToolbar
var icon: Image {
switch self {
case .more:
DS.Icon.icThreeDotsHorizontal.image
case .editToolbar:
DS.Icon.icMagicWand.image
}
}
@@ -32,6 +35,8 @@ enum InternalAction {
switch self {
case .more:
.empty
case .editToolbar:
L10n.Action.editToolbar
}
}
}
@@ -42,4 +47,12 @@ extension InternalAction: DisplayableAction {
.init(title: name, image: icon)
}
var isMoreAction: Bool {
if case .more = self {
true
} else {
false
}
}
}
@@ -29,14 +29,8 @@ struct NotificationAuthorizationService: ApplicationServiceSetUp {
self.remoteNotificationRegistrar = remoteNotificationRegistrar
}
func setUpServiceAsync() async {
await remoteNotificationRegistrar.registerForRemoteNotifications()
}
func setUpService() {
Task {
await setUpServiceAsync()
}
remoteNotificationRegistrar.registerForRemoteNotifications()
}
}
@@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "app-icon-calculator.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

@@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "app-icon-notes.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 317 KiB

@@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "app-icon-weather.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

@@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "app-icon-disguised-calculator-29@1x.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "app-icon-disguised-calculator-29@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "app-icon-disguised-calculator-29@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "app-icon-disguised-notes-29@1x.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "app-icon-disguised-notes-29@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "app-icon-disguised-notes-29@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "app-icon-preview29@1x.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "app-icon-preview29@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "app-icon-preview29@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 569 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "app-icon-disguised-weather-29@1x.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "app-icon-disguised-weather-29@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "app-icon-disguised-weather-29@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -968,7 +968,7 @@
"be" : {
"stringUnit" : {
"state" : "translated",
"value" : "Патрабуецца для захавання файлаў у дадатку «Фота»."
"value" : "Патрабуецца для захавання файлаў у праграме «Фатаграфіі»."
}
},
"ca" : {
File diff suppressed because it is too large Load Diff
@@ -16,7 +16,6 @@
// along with Proton Mail. If not, see https://www.gnu.org/licenses/.
import UIKit
import InboxDesignSystem
enum MailShortcutItem: String, CaseIterable {
case search
+12 -3
View File
@@ -183,12 +183,22 @@ extension AppContext {
}
AppLogger.log(message: "initializeUserSession finished", category: .userSessions)
} catch {
AppLogger.log(error: error, category: .userSessions)
errorSubject.send(error)
logAndDisplayError(error)
do {
try await mailSession.deleteAccount(userId: session.userId()).get()
} catch {
logAndDisplayError(error)
}
}
}
}
private func logAndDisplayError(_ error: Error) {
AppLogger.log(error: error, category: .userSessions)
errorSubject.send(error)
}
private func initializeUserSession(session: StoredSession) async throws -> MailUserSession? {
AppLogger.log(message: "Creating a new session", category: .userSessions)
@@ -209,7 +219,6 @@ extension AppContext {
let earliestNextAttemptTime = start + minimumTimeBetweenRetries
try await Task.sleep(until: earliestNextAttemptTime)
case .error(let error):
try await mailSession.deleteAccount(userId: session.userId()).get()
throw error
}
}
@@ -508,9 +508,11 @@ extension MailboxModel {
func createDraft() {
Task {
await draftPresenter.openNewDraft(onError: {
toast = .error(message: $0.localizedDescription)
})
do {
try await draftPresenter.openNewDraft()
} catch {
toast = .error(message: error.localizedDescription)
}
}
}
@@ -16,9 +16,9 @@
// along with Proton Mail. If not, see https://www.gnu.org/licenses/.
import proton_app_uniffi
import InboxDesignSystem
extension ConversationAction: DisplayableAction {
var displayData: ActionDisplayData {
switch self {
case .labelAs:
@@ -54,4 +54,12 @@ extension ConversationAction: DisplayableAction {
InternalAction.more.displayData
}
}
var isMoreAction: Bool {
if case .more = self {
true
} else {
false
}
}
}
@@ -21,67 +21,66 @@ import InboxCore
import InboxCoreUI
import SwiftUI
struct ConversationActionsSheet: View {
struct ConversationActionsMenu<OpenMenuButtonContent: View>: View {
private let conversationID: ID
private let title: String
private let mailbox: Mailbox
private let mailUserSession: MailUserSession
private let actionTapped: (ConversationAction) -> Void
private let editToolbarTapped: () -> Void
private let label: () -> OpenMenuButtonContent
@State var actions: ConversationActionSheet?
@State var isEditToolbarPresented = false
init(
conversationID: ID,
title: String,
mailbox: Mailbox,
mailUserSession: MailUserSession,
actionTapped: @escaping (ConversationAction) -> Void
actionTapped: @escaping (ConversationAction) -> Void,
editToolbarTapped: @escaping () -> Void,
label: @escaping () -> OpenMenuButtonContent
) {
self.conversationID = conversationID
self.title = title
self.mailbox = mailbox
self.mailUserSession = mailUserSession
self.actionTapped = actionTapped
self.editToolbarTapped = editToolbarTapped
self.label = label
}
var body: some View {
ClosableScreen {
ScrollView {
VStack(spacing: DS.Spacing.standard) {
if let actions {
verticalSection(actions: actions.conversationActions)
verticalSection(actions: actions.moveActions)
Menu {
Group {
if let actions {
Section {
ActionMenuButton(displayData: InternalAction.editToolbar.displayData, action: editToolbarTapped)
}
verticalSection(actions: actions.moveActions)
verticalSection(actions: actions.conversationActions)
EditToolbarSheetSection {
Task { await handle(action: .editToolbarTapped) }
}
} else {
Text(String.empty.notLocalized)
}
.padding(.all, DS.Spacing.large)
}
.background(DS.Color.BackgroundInverted.norm)
.navigationTitle(title)
.navigationBarTitleDisplayMode(.inline)
}
.onLoad { Task { await handle(action: .onLoad) } }
.sheet(isPresented: $isEditToolbarPresented) {
EditToolbarScreen(
state: .initial(toolbarType: .conversation),
customizeToolbarService: mailUserSession
)
.onLoad { Task { await handle(action: .onLoad) } }
} label: {
label()
}
}
private func verticalSection(actions: [ConversationAction]) -> some View {
ActionSheetVerticalSection(actions: actions) { action in
Task {
await handle(action: .actionTapped(action))
Section {
ForEach(actions, id: \.self) { action in
ActionMenuButton(displayData: action.displayData) {
Task {
await handle(action: .actionTapped(action))
}
}
}
}
}
private func handle(action: ConversationActionsSheetAction) async {
private func handle(action: ConversationActionsMenuAction) async {
switch action {
case .onLoad:
await loadActions()
@@ -17,7 +17,7 @@
import proton_app_uniffi
enum ConversationActionsSheetAction {
enum ConversationActionsMenuAction {
case onLoad
case actionTapped(ConversationAction)
case editToolbarTapped
@@ -33,11 +33,4 @@ extension DeleteActions {
)
}
static var dummy: Self {
.init(
message: { _, _ in .ok },
conversation: { _, _ in .ok }
)
}
}
@@ -44,9 +44,9 @@ struct DraftPresenter: ContactsDraftPresenter {
self.undoScheduleSendProvider = undoScheduleSendProvider
}
func openNewDraft(onError: (DraftOpenError) -> Void) async {
func openNewDraft() async throws(DraftOpenError) {
AppLogger.log(message: "open new draft", category: .composer)
await openNewDraft(createMode: .empty, onError: onError)
try await openNewDraft(createMode: .empty, updateDraft: .none)
}
func openDraft(withId messageId: ID, lastScheduledTime: UInt64? = nil) {
@@ -54,7 +54,7 @@ struct DraftPresenter: ContactsDraftPresenter {
draftToPresentSubject.send(.openDraftId(messageId: messageId, lastScheduledTime: lastScheduledTime))
}
func openDraft(with recipient: SingleRecipientEntry) async throws {
func openDraft(with recipient: SingleRecipientEntry) async throws(DraftOpenError) {
AppLogger.log(message: "open new draft with single recipient", category: .composer)
try await openNewEmptyDraft { toRecipients in
@@ -62,7 +62,7 @@ struct DraftPresenter: ContactsDraftPresenter {
}
}
func openDraft(with group: ContactGroupItem) async throws {
func openDraft(with group: ContactGroupItem) async throws(DraftOpenError) {
AppLogger.log(message: "open new draft with contact group details", category: .composer)
try await openNewEmptyDraft { toRecipients in
@@ -125,15 +125,9 @@ struct DraftPresenter: ContactsDraftPresenter {
try await openNewDraft(createMode: .fromIosShareExtension, updateDraft: .none)
}
func handleReplyAction(for messageId: ID, action: ReplyAction, onError: (DraftOpenError) -> Void) async {
switch action {
case .reply:
await openReplyDraft(for: messageId, onError: onError)
case .replyAll:
await openReplyAllDraft(for: messageId, onError: onError)
case .forward:
await openForwardDraft(for: messageId, onError: onError)
}
func handleReplyAction(for messageId: ID, action: ReplyAction) async throws(DraftOpenError) {
AppLogger.log(message: action.logDescription, category: .composer)
try await openNewDraft(createMode: action.createMode(messageId: messageId), updateDraft: .none)
}
func undoSentMessageAndOpenDraft(for messageId: ID) async throws(DraftUndoSendError) {
@@ -152,32 +146,6 @@ struct DraftPresenter: ContactsDraftPresenter {
extension DraftPresenter {
private func openReplyDraft(for messageId: ID, onError: (DraftOpenError) -> Void) async {
AppLogger.log(message: "open reply draft", category: .composer)
await openNewDraft(createMode: .reply(messageId), onError: onError)
}
private func openReplyAllDraft(for messageId: ID, onError: (DraftOpenError) -> Void) async {
AppLogger.log(message: "open reply all draft", category: .composer)
await openNewDraft(createMode: .replyAll(messageId), onError: onError)
}
private func openForwardDraft(for messageId: ID, onError: (DraftOpenError) -> Void) async {
AppLogger.log(message: "open forward draft", category: .composer)
await openNewDraft(createMode: .forward(messageId), onError: onError)
}
private func openNewDraft(
createMode: DraftCreateMode,
onError: (DraftOpenError) -> Void
) async {
do {
try await openNewDraft(createMode: createMode, updateDraft: .none)
} catch {
onError(error)
}
}
private func openNewDraft(
createMode: DraftCreateMode,
updateDraft: ((Draft) -> Void)?
@@ -192,7 +160,7 @@ extension DraftPresenter {
}
}
private func openNewEmptyDraft(updateToRecipients: @escaping (ComposerRecipientList) -> Void) async throws {
private func openNewEmptyDraft(updateToRecipients: @escaping (ComposerRecipientList) -> Void) async throws(DraftOpenError) {
let updateDraft: (Draft) -> Void = { draft in
updateToRecipients(draft.toRecipients())
}
@@ -202,6 +170,24 @@ extension DraftPresenter {
}
private extension ReplyAction {
var logDescription: String {
switch self {
case .reply: "open reply draft"
case .replyAll: "open reply all draft"
case .forward: "open forward draft"
}
}
func createMode(messageId: ID) -> DraftCreateMode {
switch self {
case .reply: .reply(messageId)
case .replyAll: .replyAll(messageId)
case .forward: .forward(messageId)
}
}
}
extension DraftPresenter {
static func dummy(
@@ -15,7 +15,6 @@
// You should have received a copy of the GNU General Public License
// along with Proton Mail. If not, see https://www.gnu.org/licenses/.
import InboxDesignSystem
import proton_app_uniffi
extension MessageAction: DisplayableAction {
@@ -72,4 +71,12 @@ extension MessageAction: DisplayableAction {
}
}
var isMoreAction: Bool {
if case .more = self {
true
} else {
false
}
}
}
@@ -21,23 +21,27 @@ import InboxCoreUI
import InboxDesignSystem
import SwiftUI
struct MessageActionsSheet: View {
private let state: MessageActionsSheetState
struct MessageActionsMenu<OpenMenuButtonContent: View>: View {
private let state: MessageActionsMenuState
private let mailbox: Mailbox
private let mailUserSession: MailUserSession
private let service: AllAvailableMessageActionsForActionSheetService
private let messageAppearanceOverrideStore: MessageAppearanceOverrideStore
private let actionTapped: (MessageAction) -> Void
private let editToolbarTapped: () -> Void
private let label: () -> OpenMenuButtonContent
@Environment(\.colorScheme) var colorScheme
init(
state: MessageActionsSheetState,
state: MessageActionsMenuState,
mailbox: Mailbox,
mailUserSession: MailUserSession,
messageAppearanceOverrideStore: MessageAppearanceOverrideStore,
service: @escaping AllAvailableMessageActionsForActionSheetService = allAvailableMessageActionsForActionSheet,
actionTapped: @escaping (MessageAction) -> Void,
editToolbarTapped: @escaping () -> Void,
label: @escaping () -> OpenMenuButtonContent
) {
self.state = state
self.mailbox = mailbox
@@ -45,11 +49,13 @@ struct MessageActionsSheet: View {
self.service = service
self.actionTapped = actionTapped
self.messageAppearanceOverrideStore = messageAppearanceOverrideStore
self.editToolbarTapped = editToolbarTapped
self.label = label
}
var body: some View {
StoreView(
store: MessageActionsSheetStore(
store: MessageActionsMenuStore(
state: state,
mailbox: mailbox,
messageAppearanceOverrideStore: messageAppearanceOverrideStore,
@@ -57,68 +63,71 @@ struct MessageActionsSheet: View {
actionTapped: actionTapped
)
) { state, store in
ClosableScreen {
ScrollView {
VStack(spacing: DS.Spacing.standard) {
Menu {
Group {
if store.state.actions.isEmpty {
// Workaround to ensure the menu is displayed after dynamic actions are loaded.
// Without this, the menu will not be presented.
Text(String.empty.notLocalized)
} else {
horizontalSection(actions: store.state.actions.replyActions, store: store)
verticalSection(actions: store.state.actions.messageActions, store: store)
verticalSection(actions: store.state.actions.moveActions, store: store)
verticalSection(actions: store.state.actions.generalActions, store: store)
if state.isEditToolbarVisible {
EditToolbarSheetSection {
store.handle(action: .editToolbarTapped)
Menu {
verticalSection(actions: store.state.actions.generalActions, store: store)
if state.showEditToolbar {
Section {
ActionMenuButton(
displayData: InternalAction.editToolbar.displayData,
action: editToolbarTapped
)
}
}
} label: {
Text(L10n.Action.moreOptions)
}
}
.padding(.all, DS.Spacing.large)
}
.background(DS.Color.BackgroundInverted.norm)
.navigationTitle(store.state.title)
.navigationBarTitleDisplayMode(.inline)
}
.onLoad {
store.handle(action: .colorSchemeChanged(colorScheme))
store.handle(action: .onLoad)
}
.onChange(of: colorScheme) { _, newValue in
store.handle(action: .colorSchemeChanged(newValue))
}
.sheet(isPresented: store.binding(\.isEditToolbarPresented)) {
EditToolbarScreen(state: .initial(toolbarType: .message), customizeToolbarService: mailUserSession)
.onLoad {
store.handle(action: .colorSchemeChanged(colorScheme))
store.handle(action: .onLoad)
}
.onChange(of: colorScheme) { _, newValue in
store.handle(action: .colorSchemeChanged(newValue))
}
} label: {
label()
}
}
}
private func horizontalSection(actions: [MessageAction], store: MessageActionsSheetStore) -> some View {
HStack(spacing: DS.Spacing.standard) {
@ViewBuilder
private func horizontalSection(actions: [MessageAction], store: MessageActionsMenuStore) -> some View {
ControlGroup {
ForEach(actions, id: \.self) { action in
horizontalButton(action: action, store: store)
ActionMenuButton(displayData: action.displayData) {
store.handle(action: .actionTapped(action))
}
}
}
}
private func verticalSection(actions: [MessageAction], store: MessageActionsSheetStore) -> some View {
ActionSheetVerticalSection(actions: actions) { action in
store.handle(action: .actionTapped(action))
}
}
private func horizontalButton(action: MessageAction, store: MessageActionsSheetStore) -> some View {
Button(action: { store.handle(action: .actionTapped(action)) }) {
VStack(spacing: DS.Spacing.standard) {
action.displayData.image
.square(size: 24)
.foregroundStyle(DS.Color.Icon.norm)
Text(action.displayData.title)
.font(.body)
.foregroundStyle(DS.Color.Text.norm)
@ViewBuilder
private func verticalSection(actions: [MessageAction], store: MessageActionsMenuStore) -> some View {
Section {
ForEach(actions, id: \.self) { action in
ActionMenuButton(displayData: action.displayData) {
store.handle(action: .actionTapped(action))
}
}
.frame(height: 84)
.frame(maxWidth: .infinity)
}
.buttonStyle(RegularButtonStyle())
.background(DS.Color.BackgroundInverted.secondary)
.clipShape(.rect(cornerRadius: DS.Radius.extraLarge))
}
}
extension MessageActionSheet {
var isEmpty: Bool {
replyActions.isEmpty && generalActions.isEmpty && messageActions.isEmpty && moveActions.isEmpty
}
}
@@ -18,9 +18,8 @@
import proton_app_uniffi
import SwiftUI
enum MessageActionsSheetAction {
enum MessageActionsMenuAction {
case onLoad
case actionTapped(MessageAction)
case colorSchemeChanged(ColorScheme)
case editToolbarTapped
}
@@ -19,30 +19,26 @@ import proton_app_uniffi
import InboxCore
import SwiftUI
struct MessageActionsSheetState: Copying, Equatable {
struct MessageActionsMenuState: Copying, Equatable {
let messageID: ID
let title: String
let isEditToolbarVisible: Bool
let showEditToolbar: Bool
var actions: MessageActionSheet
var colorScheme: ColorScheme
var isEditToolbarPresented: Bool
}
extension MessageActionsSheetState {
extension MessageActionsMenuState {
static func initial(messageID: ID, title: String, isEditToolbarVisible: Bool) -> Self {
static func initial(messageID: ID, showEditToolbar: Bool) -> Self {
.init(
messageID: messageID,
title: title,
isEditToolbarVisible: isEditToolbarVisible,
showEditToolbar: showEditToolbar,
actions: .init(
replyActions: [],
messageActions: [],
moveActions: [],
generalActions: []
),
colorScheme: .light,
isEditToolbarPresented: false
colorScheme: .light
)
}
@@ -21,8 +21,8 @@ import InboxCoreUI
import SwiftUI
@MainActor
class MessageActionsSheetStore: StateStore {
@Published var state: MessageActionsSheetState
class MessageActionsMenuStore: StateStore {
@Published var state: MessageActionsMenuState
private let mailbox: Mailbox
private let service: AllAvailableMessageActionsForActionSheetService
@@ -30,7 +30,7 @@ class MessageActionsSheetStore: StateStore {
private let messageAppearanceOverrideStore: MessageAppearanceOverrideStore
init(
state: MessageActionsSheetState,
state: MessageActionsMenuState,
mailbox: Mailbox,
messageAppearanceOverrideStore: MessageAppearanceOverrideStore,
service: @escaping AllAvailableMessageActionsForActionSheetService,
@@ -43,7 +43,7 @@ class MessageActionsSheetStore: StateStore {
self.actionTapped = actionTapped
}
func handle(action: MessageActionsSheetAction) async {
func handle(action: MessageActionsMenuAction) async {
switch action {
case .onLoad:
await loadActions()
@@ -51,8 +51,6 @@ class MessageActionsSheetStore: StateStore {
actionTapped(action)
case .colorSchemeChanged(let colorScheme):
state = state.copy(\.colorScheme, to: colorScheme)
case .editToolbarTapped:
state = state.copy(\.isEditToolbarPresented, to: true)
}
}
@@ -15,6 +15,7 @@
// You should have received a copy of the GNU General Public License
// along with Proton Mail. If not, see https://www.gnu.org/licenses/.
protocol DisplayableAction {
protocol DisplayableAction: Hashable {
var displayData: ActionDisplayData { get }
var isMoreAction: Bool { get }
}
@@ -19,7 +19,7 @@ import proton_app_uniffi
import WebKit
@MainActor
final class MessagePrinter: PrintActionPerformer {
final class MessagePrinter {
typealias FindMessage = (ID) async throws -> Message?
typealias PresentPrintInteractionController = (WebViewPrintingTransaction) async throws(PrintError) -> Void
@@ -32,7 +32,7 @@ final class WebViewPrintingTransaction {
self.webView = webView
}
func perform<Output>(block: (Message, WKWebView) async throws(PrintError) -> Output) async throws(PrintError) -> Output {
func perform<Output: Sendable>(block: (Message, WKWebView) async throws(PrintError) -> Output) async throws(PrintError) -> Output {
let headerImage = renderHeaderImage(
subject: message.subject,
messageDetails: message.toExpandedMessageCellUIModel().messageDetails
@@ -38,13 +38,4 @@ extension ReadActionPerformerActions {
)
}
static var dummy: Self {
.init(
markMessageAsRead: { _, _ in .ok },
markConversationAsRead: { _, _ in .ok },
markMessageAsUnread: { _, _ in .ok },
markConversationAsUnread: { _, _ in .ok }
)
}
}
@@ -21,7 +21,6 @@ import InboxIAP
import SwiftUI
import proton_app_uniffi
@MainActor
class SnoozeStore: StateStore {
@Published var state: SnoozeState
private let upsellScreenPresenter: UpsellScreenPresenter
@@ -38,13 +38,4 @@ extension StarActionPerformerActions {
)
}
static var dummy: StarActionPerformerActions {
.init(
starMessage: { _, _ in .ok },
starConversation: { _, _ in .ok },
unstarMessage: { _, _ in .ok },
unstarConversation: { _, _ in .ok }
)
}
}
@@ -15,27 +15,30 @@
// You should have received a copy of the GNU General Public License
// along with Proton Mail. If not, see https://www.gnu.org/licenses/.
import InboxCoreUI
import InboxDesignSystem
import SwiftUI
struct ActionSheetVerticalSection<Action: DisplayableAction>: View {
private let actions: [Action]
private let actionSelected: (Action) -> Void
struct ActionMenuButton: View {
private let displayData: ActionDisplayData
private let action: () -> Void
init(actions: [Action], actionSelected: @escaping (Action) -> Void) {
self.actions = actions
self.actionSelected = actionSelected
init(displayData: ActionDisplayData, action: @escaping () -> Void) {
self.displayData = displayData
self.action = action
}
var body: some View {
ActionSheetSection {
ForEachLast(collection: actions) { action, isLast in
ActionSheetImageButton(
displayData: action.displayData,
displayBottomSeparator: !isLast
) {
actionSelected(action)
}
Button {
action()
} label: {
Label {
Text(displayData.title)
.font(.body)
.foregroundStyle(DS.Color.Text.norm)
} icon: {
displayData.image
.square(size: 24)
.foregroundStyle(DS.Color.Icon.norm)
}
}
}
@@ -22,7 +22,7 @@ import SwiftUI
extension View {
func listActionsToolbar(
state: ListActionsToolbarState,
initialState: ListActionsToolbarState,
availableActions: AvailableListToolbarActions,
itemTypeForActionBar: MailboxItemType,
mailUserSession: MailUserSession,
@@ -30,7 +30,7 @@ extension View {
) -> some View {
modifier(
ListActionBarViewModifier(
state: state,
initialState: initialState,
availableActions: availableActions,
itemTypeForActionBar: itemTypeForActionBar,
mailUserSession: mailUserSession,
@@ -40,11 +40,14 @@ extension View {
}
private struct ListActionBarViewModifier: ViewModifier {
typealias State = ListActionsToolbarState
typealias Store = ListActionsToolbarStore
@Binding var selectedItems: Set<MailboxSelectedItem>
@EnvironmentObject var mailbox: Mailbox
@EnvironmentObject var toastStateStore: ToastStateStore
@EnvironmentObject var refreshToolbarNotifier: RefreshToolbarNotifier
private let state: ListActionsToolbarState
private let initialState: ListActionsToolbarState
private let itemTypeForActionBar: MailboxItemType
private let availableActions: AvailableListToolbarActions
private let deleteActions: DeleteActions
@@ -54,7 +57,7 @@ private struct ListActionBarViewModifier: ViewModifier {
private let readActionPerformerActions: ReadActionPerformerActions
init(
state: ListActionsToolbarState,
initialState: ListActionsToolbarState,
availableActions: AvailableListToolbarActions,
starActionPerformerActions: StarActionPerformerActions = .productionInstance,
readActionPerformerActions: ReadActionPerformerActions = .productionInstance,
@@ -65,7 +68,7 @@ private struct ListActionBarViewModifier: ViewModifier {
selectedItems: Binding<Set<MailboxSelectedItem>>
) {
self._selectedItems = selectedItems
self.state = state
self.initialState = initialState
self.itemTypeForActionBar = itemTypeForActionBar
self.availableActions = availableActions
self.deleteActions = deleteActions
@@ -78,7 +81,7 @@ private struct ListActionBarViewModifier: ViewModifier {
func body(content: Content) -> some View {
StoreView(
store: ListActionsToolbarStore(
state: state,
state: initialState,
availableActions: availableActions,
starActionPerformerActions: starActionPerformerActions,
readActionPerformerActions: readActionPerformerActions,
@@ -116,16 +119,8 @@ private struct ListActionBarViewModifier: ViewModifier {
store.handle(action: .dismissMoveToSheet)
}
)
.sheet(item: store.binding(\.moreActionSheetPresented)) { state in
ListActionsToolbarMoreSheet(state: state) { action in
store.handle(action: .moreSheetAction(action, ids: selectedItemsIDs))
} editToolbarTapped: {
store.handle(action: .editToolbarTapped)
}
.alert(model: store.binding(\.moreDeleteConfirmationAlert))
.sheet(isPresented: store.binding(\.isEditToolbarSheetPresented)) {
EditToolbarScreen(state: .initial(toolbarType: .list), customizeToolbarService: mailUserSession)
}
.sheet(isPresented: store.binding(\.isEditToolbarSheetPresented)) {
EditToolbarScreen(state: .initial(toolbarType: .list), customizeToolbarService: mailUserSession)
}
.sheet(isPresented: store.binding(\.isSnoozeSheetPresented)) {
SnoozeView(
@@ -151,21 +146,14 @@ private struct ListActionBarViewModifier: ViewModifier {
selectedItems.map(\.id)
}
private func toolbarContent(
state: ListActionsToolbarState,
store: ListActionsToolbarStore
) -> some ToolbarContent {
private func toolbarContent(state: State, store: Store) -> some ToolbarContent {
ToolbarItemGroup(placement: .bottomBar) {
HStack {
ForEachEnumerated(state.bottomBarActions, id: \.element) { action, index in
if index == 0 {
Spacer()
}
Button(action: { store.handle(action: .actionSelected(action, ids: selectedItemsIDs)) }) {
action.displayData.image
.foregroundStyle(DS.Color.Icon.weak)
}
.accessibilityIdentifier(MailboxActionBarViewIdentifiers.button(index: index))
toolbarItem(for: action, state: state, store: store)
Spacer()
}
}
@@ -176,6 +164,37 @@ private struct ListActionBarViewModifier: ViewModifier {
}
}
@ViewBuilder
private func toolbarItem(
for action: ListActions,
state: State,
store: Store
) -> some View {
if action == .more {
Menu(
content: {
ActionMenuButton(displayData: InternalAction.editToolbar.displayData) {
store.handle(action: .editToolbarTapped)
}
Section {
ForEach(state.moreSheetOnlyActions.reversed(), id: \.self) { action in
ActionMenuButton(displayData: action.displayData) {
store.handle(action: .actionSelected(action, ids: selectedItemsIDs))
}
}
}
},
label: {
action.displayData.image
.foregroundStyle(DS.Color.Icon.weak)
})
} else {
Button(action: { store.handle(action: .actionSelected(action, ids: selectedItemsIDs)) }) {
action.displayData.image
.foregroundStyle(DS.Color.Icon.weak)
}
}
}
}
// MARK: Accessibility
@@ -1,82 +0,0 @@
// Copyright (c) 2024 Proton Technologies AG
//
// This file is part of Proton Mail.
//
// Proton Mail 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.
//
// Proton Mail 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 Proton Mail. If not, see https://www.gnu.org/licenses/.
import proton_app_uniffi
import InboxCoreUI
import InboxDesignSystem
import SwiftUI
struct ListActionsToolbarMoreSheet: View {
let state: ListActionsToolbarMoreSheetState
let actionTapped: (ListActions) -> Void
let editToolbarTapped: () -> Void
init(
state: ListActionsToolbarMoreSheetState,
actionTapped: @escaping (ListActions) -> Void,
editToolbarTapped: @escaping () -> Void
) {
self.state = state
self.actionTapped = actionTapped
self.editToolbarTapped = editToolbarTapped
}
var body: some View {
ClosableScreen {
ScrollView {
VStack(spacing: DS.Spacing.large) {
section(content: state.moreSheetOnlyActions)
section(content: state.bottomBarActions)
editToolbarSection()
}
.padding(.all, DS.Spacing.large)
}
.background(DS.Color.Background.secondary)
.navigationTitle(L10n.Mailbox.selected(emailsCount: state.selectedItemsIDs.count).string)
.navigationBarTitleDisplayMode(.inline)
}
}
// MARK: - Private
private func section(content: [ListActions]) -> some View {
ActionSheetSection {
ForEachLast(collection: content) { action, isLast in
ActionSheetImageButton(
displayData: action.displayData,
displayBottomSeparator: !isLast,
action: { actionTapped(action) }
)
}
}
}
private func editToolbarSection() -> some View {
EditToolbarSheetSection {
editToolbarTapped()
}
}
}
#Preview {
ListActionsToolbarMoreSheet(
state: ListActionsToolbarMoreSheetPreviewProvider.state(),
actionTapped: { _ in },
editToolbarTapped: {}
)
}
@@ -1,40 +0,0 @@
// Copyright (c) 2024 Proton Technologies AG
//
// This file is part of Proton Mail.
//
// Proton Mail 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.
//
// Proton Mail 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 Proton Mail. If not, see https://www.gnu.org/licenses/.
import Foundation
enum ListActionsToolbarMoreSheetPreviewProvider {
static func state() -> ListActionsToolbarMoreSheetState {
.init(
selectedItemsIDs: [.init(value: 1), .init(value: 2), .init(value: 3)],
bottomBarActions: [
.markUnread,
.moveToSystemFolder(.init(localId: .init(value: 4), name: .archive)),
.moveToSystemFolder(.init(localId: .init(value: 5), name: .inbox)),
.moveToSystemFolder(.init(localId: .init(value: 6), name: .trash)),
.star,
],
moreSheetOnlyActions: [
.labelAs,
.moveTo,
.moveToSystemFolder(.init(localId: .init(value: 7), name: .spam)),
]
)
}
}
@@ -20,7 +20,6 @@ import InboxCoreUI
import proton_app_uniffi
import SwiftUI
@MainActor
final class ListActionsToolbarStore: StateStore {
@Published var state: ListActionsToolbarState
@@ -89,50 +88,34 @@ final class ListActionsToolbarStore: StateStore {
private func handle(action: ListActions, ids: [ID]) async {
switch action {
case .more:
let moreActionSheetState = ListActionsToolbarMoreSheetState(
selectedItemsIDs: ids,
bottomBarActions: state.bottomBarActions.moreActionFiltered,
moreSheetOnlyActions: state.moreSheetOnlyActions
)
state =
state
.copy(\.moreActionSheetPresented, to: moreActionSheetState)
case .labelAs:
dismissMoreActionSheet()
state =
state
.copy(\.labelAsSheetPresented, to: .init(sheetType: .labelAs, ids: ids, mailboxItem: itemTypeForActionBar.mailboxItem))
case .moveTo:
dismissMoreActionSheet()
state =
state
.copy(\.moveToSheetPresented, to: .init(sheetType: .moveTo, ids: ids, mailboxItem: itemTypeForActionBar.mailboxItem))
case .star:
dismissMoreActionSheet()
await starActionPerformer.star(itemsWithIDs: ids, itemType: itemTypeForActionBar)
case .unstar:
dismissMoreActionSheet()
await starActionPerformer.unstar(itemsWithIDs: ids, itemType: itemTypeForActionBar)
case .markRead:
dismissMoreActionSheet()
await readActionPerformer.markAsRead(itemsWithIDs: ids, itemType: itemTypeForActionBar)
case .markUnread:
dismissMoreActionSheet()
await readActionPerformer.markAsUnread(itemsWithIDs: ids, itemType: itemTypeForActionBar)
case .permanentDelete:
let keyPath: WritableKeyPath<ListActionsToolbarState, AlertModel?> =
state.moreActionSheetPresented != nil ? \.moreDeleteConfirmationAlert : \.deleteConfirmationAlert
let alert: AlertModel = .deleteConfirmation(
itemsCount: ids.count,
action: { [weak self] action in self?.handle(action: .alertActionTapped(action, ids: ids)) }
)
state = state.copy(keyPath, to: alert)
state = state.copy(\.deleteConfirmationAlert, to: alert)
case .moveToSystemFolder(let model), .notSpam(let model):
await performMoveToAction(destination: model, ids: ids)
case .snooze:
dismissMoreActionSheet()
state = state.copy(\.isSnoozeSheetPresented, to: true)
case .more:
break
}
}
@@ -158,7 +141,6 @@ final class ListActionsToolbarStore: StateStore {
state =
state
.copy(\.deleteConfirmationAlert, to: nil)
.copy(\.moreDeleteConfirmationAlert, to: nil)
switch action {
case .delete:
await deleteActionsPerformer.delete(itemsWithIDs: ids, itemType: itemType)
@@ -182,13 +164,8 @@ final class ListActionsToolbarStore: StateStore {
.copy(\.moreSheetOnlyActions, to: actions.hiddenListActions)
}
private func dismissMoreActionSheet() {
state = state.copy(\.moreActionSheetPresented, to: nil)
}
private func itemDeleted() {
toastStateStore.present(toast: .deleted())
dismissMoreActionSheet()
}
private func handleMoveActionSuccess(
@@ -199,12 +176,10 @@ final class ListActionsToolbarStore: StateStore {
let destinationName = destination.name.displayData.title.string
let toast: Toast = .moveTo(id: toastID, destinationName: destinationName, undoAction: undoAction)
toastStateStore.present(toast: toast)
dismissMoreActionSheet()
}
private func handleMoveActionFailure(error: Error) {
toastStateStore.present(toast: .error(message: error.localizedDescription))
dismissMoreActionSheet()
}
private func dismissToast(withID toastID: UUID) {
@@ -22,11 +22,9 @@ import InboxCoreUI
struct ListActionsToolbarState: Copying, Equatable {
var bottomBarActions: [ListActions]
var moreSheetOnlyActions: [ListActions]
var moreActionSheetPresented: ListActionsToolbarMoreSheetState?
var labelAsSheetPresented: ActionSheetInput?
var moveToSheetPresented: ActionSheetInput?
var deleteConfirmationAlert: AlertModel?
var moreDeleteConfirmationAlert: AlertModel?
var isSnoozeSheetPresented: Bool
var isEditToolbarSheetPresented: Bool
}
@@ -36,11 +34,9 @@ extension ListActionsToolbarState {
.init(
bottomBarActions: [],
moreSheetOnlyActions: [],
moreActionSheetPresented: nil,
labelAsSheetPresented: nil,
moveToSheetPresented: nil,
deleteConfirmationAlert: nil,
moreDeleteConfirmationAlert: nil,
isSnoozeSheetPresented: false,
isEditToolbarSheetPresented: false
)
@@ -16,7 +16,6 @@
// along with Proton Mail. If not, see https://www.gnu.org/licenses/.
import proton_app_uniffi
import InboxDesignSystem
extension MovableSystemFolder {
var displayData: ActionDisplayData {
@@ -22,3 +22,9 @@ struct MoveToState: Copying {
var moveToCustomFolderActions: [MoveToCustomFolder]
var createFolderLabelPresented: Bool
}
extension MoveToState {
static var initial: Self {
.init(moveToSystemFolderActions: [], moveToCustomFolderActions: [], createFolderLabelPresented: false)
}
}
@@ -23,6 +23,7 @@ import InboxCore
struct MoveToSheet: View {
@EnvironmentObject var toastStateStore: ToastStateStore
private let initialState: MoveToState
private let input: ActionSheetInput
private let mailbox: Mailbox
private let availableMoveToActions: AvailableMoveToActions
@@ -31,6 +32,7 @@ struct MoveToSheet: View {
private let mailUserSession: MailUserSession
init(
initialState: MoveToState = .initial,
input: ActionSheetInput,
mailbox: Mailbox,
availableMoveToActions: AvailableMoveToActions,
@@ -38,6 +40,7 @@ struct MoveToSheet: View {
navigation: @escaping (MoveToSheetNavigation) -> Void,
mailUserSession: MailUserSession
) {
self.initialState = initialState
self.input = input
self.mailbox = mailbox
self.availableMoveToActions = availableMoveToActions
@@ -49,6 +52,7 @@ struct MoveToSheet: View {
var body: some View {
StoreView(
store: MoveToSheetStateStore(
state: initialState,
input: input,
mailbox: mailbox,
availableMoveToActions: availableMoveToActions,
@@ -23,25 +23,42 @@ enum MoveToSheetPreviewProvider {
static var availableMoveToActions: AvailableMoveToActions {
.init(
message: { _, _ in
.ok([
.systemFolder(.init(localId: .init(value: 1), name: .inbox)),
.systemFolder(.init(localId: .init(value: 2), name: .archive)),
.customFolder(customFoldersTree),
.customFolder(
.init(
localId: .init(value: 6),
name: "4",
color: .init(value: "#9E221A"),
children: []
)),
])
.ok(systemFolderActions + customFolderActions)
},
conversation: { _, _ in .ok([]) }
)
}
static func state() -> MoveToState {
.init(
moveToSystemFolderActions: systemFolderActions.compactMap(\.moveToSystemFolder),
moveToCustomFolderActions: customFolderActions.compactMap(\.moveToCustomFolder),
createFolderLabelPresented: false
)
}
// MARK: - Private
private static var systemFolderActions: [MoveAction] {
[
.systemFolder(.init(localId: .init(value: 1), name: .inbox)),
.systemFolder(.init(localId: .init(value: 2), name: .archive)),
]
}
private static var customFolderActions: [MoveAction] {
[
.customFolder(customFoldersTree),
.customFolder(
.init(
localId: .init(value: 6),
name: "4",
color: .init(value: "#9E221A"),
children: []
)),
]
}
private static var customFoldersTree: CustomFolderAction {
.init(
localId: .init(value: 3),
@@ -23,7 +23,7 @@ import proton_app_uniffi
import SwiftUI
class MoveToSheetStateStore: StateStore {
@Published var state: MoveToState = .initial
@Published var state: MoveToState
private let input: ActionSheetInput
private let moveToActionsProvider: MoveToActionsProvider
@@ -33,6 +33,7 @@ class MoveToSheetStateStore: StateStore {
private let mailUserSession: MailUserSession
init(
state: MoveToState,
input: ActionSheetInput,
mailbox: Mailbox,
availableMoveToActions: AvailableMoveToActions,
@@ -41,6 +42,7 @@ class MoveToSheetStateStore: StateStore {
navigation: @escaping (MoveToSheetNavigation) -> Void,
mailUserSession: MailUserSession
) {
self.state = state
self.input = input
self.moveToActionsProvider = .init(mailbox: mailbox, availableMoveToActions: availableMoveToActions)
self.toastStateStore = toastStateStore
@@ -49,14 +51,14 @@ class MoveToSheetStateStore: StateStore {
self.mailUserSession = mailUserSession
}
func handle(action: MoveToSheetAction) {
func handle(action: MoveToSheetAction) async {
switch action {
case .viewAppear:
loadMoveToActions()
await loadMoveToActions()
case .customFolderTapped(let customFolder):
moveTo(destinationID: customFolder.id, destinationName: customFolder.name)
await moveTo(destinationID: customFolder.id, destinationName: customFolder.name)
case .systemFolderTapped(let systemFolder):
moveTo(destinationID: systemFolder.id, destinationName: systemFolder.label.displayData.title.string)
await moveTo(destinationID: systemFolder.id, destinationName: systemFolder.label.displayData.title.string)
case .createFolderTapped:
state = state.copy(\.createFolderLabelPresented, to: true)
}
@@ -64,51 +66,36 @@ class MoveToSheetStateStore: StateStore {
// MARK: - Private
private func moveTo(destinationID: ID, destinationName: String) {
Task { [weak self, mailUserSession] in
guard let self else { return }
do {
let undo = try await moveToActionPerformer.moveTo(
destinationID: destinationID,
itemsIDs: input.ids,
itemType: input.mailboxItem.itemType
)
let toastID = UUID()
let undoAction = undo.undoAction(userSession: mailUserSession) {
self.dismissToast(withID: toastID)
}
let toast: Toast = .moveTo(id: toastID, destinationName: destinationName, undoAction: undoAction)
dismissSheet(presentingToast: toast)
} catch {
dismissSheet(presentingToast: .error(message: error.localizedDescription))
private func moveTo(destinationID: ID, destinationName: String) async {
do {
let undo = try await moveToActionPerformer.moveTo(
destinationID: destinationID,
itemsIDs: input.ids,
itemType: input.mailboxItem.itemType
)
let toastID = UUID()
let undoAction = undo.undoAction(userSession: mailUserSession) {
self.dismissToast(withID: toastID)
}
let toast: Toast = .moveTo(id: toastID, destinationName: destinationName, undoAction: undoAction)
dismissSheet(presentingToast: toast)
} catch {
dismissSheet(presentingToast: .error(message: error.localizedDescription))
}
}
private func dismissToast(withID toastID: UUID) {
Dispatcher.dispatchOnMain(
.init(block: { [weak self] in
self?.toastStateStore.dismiss(withID: toastID)
}))
toastStateStore.dismiss(withID: toastID)
}
private func dismissSheet(presentingToast toast: Toast) {
Dispatcher.dispatchOnMain(
.init { [weak self, input] in
self?.toastStateStore.present(toast: toast)
self?.navigation(input.mailboxItem.shouldGoBack ? .dismissAndGoBack : .dismiss)
})
toastStateStore.present(toast: toast)
navigation(input.mailboxItem.shouldGoBack ? .dismissAndGoBack : .dismiss)
}
private func loadMoveToActions() {
Task {
let actions = await moveToActionsProvider.actions(for: input.mailboxItem.itemType, ids: input.ids)
Dispatcher.dispatchOnMain(
.init(block: { [weak self] in
self?.update(moveToActions: actions)
}))
}
private func loadMoveToActions() async {
let actions = await moveToActionsProvider.actions(for: input.mailboxItem.itemType, ids: input.ids)
update(moveToActions: actions)
}
private func update(moveToActions: [MoveAction]) {
@@ -119,7 +106,7 @@ class MoveToSheetStateStore: StateStore {
}
}
private extension MoveAction {
extension MoveAction {
var moveToSystemFolder: MoveToSystemFolder? {
guard case .systemFolder(let model) = self else {
@@ -149,9 +136,3 @@ private extension CustomFolderAction {
}
}
private extension MoveToState {
static var initial: Self {
.init(moveToSystemFolderActions: [], moveToCustomFolderActions: [], createFolderLabelPresented: false)
}
}
@@ -18,19 +18,17 @@
import proton_app_uniffi
import SwiftUI
@MainActor
class AppAppearanceStore: ObservableObject {
@MainActor
static let shared = AppAppearanceStore(mailSession: { AppContext.shared.mailSession })
@Published var colorScheme: ColorScheme?
private let mailSession: () -> MailSessionProtocol
@MainActor
init(mailSession: @escaping () -> MailSessionProtocol) {
self.mailSession = mailSession
}
@MainActor
func updateColorScheme() async {
let appearance = try! await mailSession().getAppSettings().get().appearance
switch appearance {
@@ -0,0 +1,65 @@
// Copyright (c) 2025 Proton Technologies AG
//
// This file is part of Proton Mail.
//
// Proton Mail 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.
//
// Proton Mail 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 Proton Mail. If not, see https://www.gnu.org/licenses/.
import SwiftUI
enum AppIcon: CaseIterable, Hashable {
case `default`
case notes
case weather
case calculator
init(rawValue: String?) {
switch rawValue {
case Self.appIconNotes: self = .notes
case Self.appIconWeather: self = .weather
case Self.appIconCalculator: self = .calculator
default: self = .default
}
}
var title: LocalizedStringResource {
switch self {
case .default: return L10n.Settings.AppIcon.primary
case .notes: return L10n.Settings.AppIcon.notes
case .weather: return L10n.Settings.AppIcon.weather
case .calculator: return L10n.Settings.AppIcon.calculator
}
}
var alternateIconName: String? {
switch self {
case .default: nil
case .notes: Self.appIconNotes
case .weather: Self.appIconWeather
case .calculator: Self.appIconCalculator
}
}
var preview: ImageResource {
switch self {
case .default: return ImageResource.appIconPreview
case .weather: return ImageResource.appIconWeatherPreview
case .notes: return ImageResource.appIconNotesPreview
case .calculator: return ImageResource.appIconCalculatorPreview
}
}
private static let appIconNotes: String = "AppIcon-notes"
private static let appIconWeather: String = "AppIcon-weather"
private static let appIconCalculator: String = "AppIcon-calculator"
}
@@ -0,0 +1,28 @@
// Copyright (c) 2025 Proton Technologies AG
//
// This file is part of Proton Mail.
//
// Proton Mail 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.
//
// Proton Mail 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 Proton Mail. If not, see https://www.gnu.org/licenses/.
import UIKit
protocol AppIconConfigurable {
var alternateIconName: String? { get }
var supportsAlternateIcons: Bool { get }
@MainActor
func setAlternateIconName(_ alternateIconName: String?) async throws
}
extension UIApplication: AppIconConfigurable {}
@@ -44,7 +44,6 @@ class AppProtectionSelectionStore: StateStore {
self.laContext = laContext
}
@MainActor
func handle(action: AppProtectionSelectionAction) async {
switch action {
case .onAppear:
@@ -73,7 +72,6 @@ class AppProtectionSelectionStore: StateStore {
}
}
@MainActor
private func reloadProtectionData() async {
guard let settings = await currentAppSettings() else { return }
let protection = settings.protection
@@ -82,7 +80,6 @@ class AppProtectionSelectionStore: StateStore {
.copy(\.autoLock, to: settings.autoLock)
}
@MainActor
private func disableProtection() async {
switch state.currentProtection {
case .biometrics:
@@ -94,7 +91,6 @@ class AppProtectionSelectionStore: StateStore {
}
}
@MainActor
private func disableBiometricProtection() async {
guard await biometricAuthDidSucceed() else { return }
do {
@@ -104,7 +100,6 @@ class AppProtectionSelectionStore: StateStore {
}
}
@MainActor
private func enableBiometricProtection() async {
switch state.currentProtection {
case .none:
@@ -120,7 +115,6 @@ class AppProtectionSelectionStore: StateStore {
}
}
@MainActor
private func setPINProtection() async {
switch state.currentProtection {
case .none:
@@ -134,7 +128,6 @@ class AppProtectionSelectionStore: StateStore {
}
}
@MainActor
private func currentAppSettings() async -> AppSettings? {
do {
return try await appSettingsRepository.getAppSettings().get()
@@ -144,7 +137,6 @@ class AppProtectionSelectionStore: StateStore {
}
}
@MainActor
private func availableAppProtectionMethods(selected: AppProtection?) -> [AppProtectionMethodViewModel] {
let availableMethods: [AppProtectionMethodViewModel.MethodType] =
[.none, .pin] + [supportedBiometry()].compactMap { $0 }
@@ -157,7 +149,6 @@ class AppProtectionSelectionStore: StateStore {
}
}
@MainActor
private func supportedBiometry() -> AppProtectionMethodViewModel.MethodType? {
switch SupportedBiometry.configuredOnDevice(context: laContext()) {
case .none:
@@ -169,7 +160,6 @@ class AppProtectionSelectionStore: StateStore {
}
}
@MainActor
private func biometricAuthDidSucceed() async -> Bool {
await biometricAuthenticator.authenticate().isSuccess
}
@@ -26,4 +26,5 @@ enum AppSettingsAction {
case appearanceSelected(AppAppearance)
case combinedContactsChanged(Bool)
case alternativeRoutingChanged(Bool)
case appIconSelected(AppIcon)
}
@@ -27,17 +27,21 @@ struct AppSettingsScreen: View {
@EnvironmentObject var appAppearanceStore: AppAppearanceStore
@EnvironmentObject var router: Router<SettingsRoute>
@StateObject var store: AppSettingsStateStore
private let appIconConfigurator: AppIconConfigurable
init(
state: AppSettingsState = .initial,
state: AppSettingsState? = .none,
appIconConfigurator: AppIconConfigurable = UIApplication.shared,
appSettingsRepository: AppSettingsRepository = AppContext.shared.mailSession
) {
_store = .init(
wrappedValue: .init(
state: state,
appSettingsRepository: appSettingsRepository
state: state ?? .initial(appIconName: appIconConfigurator.alternateIconName),
appSettingsRepository: appSettingsRepository,
appIconConfigurator: appIconConfigurator
)
)
self.appIconConfigurator = appIconConfigurator
}
var body: some View {
@@ -69,6 +73,9 @@ struct AppSettingsScreen: View {
value: store.state.storedAppSettings.protection.humanReadable.string,
action: { router.go(to: .appProtection) }
)
if appIconConfigurator.supportsAlternateIcons {
appIconButton
}
}
FormSection(footer: L10n.Settings.App.combinedContactsInfo) {
FormSwitchView(
@@ -159,6 +166,30 @@ struct AppSettingsScreen: View {
)
}
@ViewBuilder
private var appIconButton: some View {
Menu(
content: {
ForEach(AppIcon.allCases.filter { icon in store.state.appIcon != icon }, id: \.self) { icon in
Button(action: { store.handle(action: .appIconSelected(icon)) }) {
HStack(spacing: DS.Spacing.medium) {
Text(icon.title)
Image(icon.preview)
}
}
}
},
label: {
FormBigButton(
title: L10n.Settings.AppIcon.buttonTitle,
symbol: .chevronUpChevronDown,
value: store.state.appIcon.title.string,
action: {}
)
}
)
}
private var combinedContactsBinding: Binding<Bool> {
.init(
get: { store.state.storedAppSettings.useCombineContacts },
@@ -187,7 +218,7 @@ struct AppSettingsScreen: View {
#Preview {
NavigationStack {
AppSettingsScreen(state: .initial)
AppSettingsScreen(state: .initial(appIconName: .none))
}
}
@@ -19,16 +19,17 @@ import proton_app_uniffi
import InboxCore
import SwiftUI
struct AppSettingsState: Copying {
struct AppSettingsState: Copying, Equatable {
var areNotificationsEnabled: Bool
var appLanguage: String
var storedAppSettings: AppSettings
var appIcon: AppIcon
var isAppearanceMenuShown: Bool
}
extension AppSettingsState {
static var initial: Self {
static func initial(appIconName: String?) -> Self {
.init(
areNotificationsEnabled: false,
appLanguage: .empty,
@@ -39,6 +40,7 @@ extension AppSettingsState {
useCombineContacts: false,
useAlternativeRouting: true
),
appIcon: AppIcon(rawValue: appIconName),
isAppearanceMenuShown: false
)
}
@@ -26,13 +26,14 @@ final class AppSettingsStateStore: StateStore, Sendable {
private let notificationCenter: UserNotificationCenter
private let urlOpener: URLOpener
private let appLangaugeProvider: AppLangaugeProvider
private let appIconConfigurator: AppIconConfigurable
@MainActor
init(
state: AppSettingsState,
appSettingsRepository: AppSettingsRepository,
notificationCenter: UserNotificationCenter = UNUserNotificationCenter.current(),
urlOpener: URLOpener = UIApplication.shared,
appIconConfigurator: AppIconConfigurable,
currentLocale: Locale = Locale.current,
mainBundle: Bundle = Bundle.main
) {
@@ -40,10 +41,10 @@ final class AppSettingsStateStore: StateStore, Sendable {
self.appSettingsRepository = appSettingsRepository
self.notificationCenter = notificationCenter
self.urlOpener = urlOpener
self.appIconConfigurator = appIconConfigurator
self.appLangaugeProvider = .init(currentLocale: currentLocale, mainBundle: mainBundle)
}
@MainActor
func handle(action: AppSettingsAction) async {
switch action {
case .notificationButtonTapped:
@@ -63,9 +64,21 @@ final class AppSettingsStateStore: StateStore, Sendable {
await update(setting: \.useCombineContacts, value: value)
case .alternativeRoutingChanged(let value):
await update(setting: \.useAlternativeRouting, value: value)
case .appIconSelected(let appIcon):
await updateAppIcon(appIcon)
}
}
@MainActor
func updateAppIcon(_ icon: AppIcon) async {
guard appIconConfigurator.supportsAlternateIcons else {
return
}
try? await appIconConfigurator.setAlternateIconName(icon.alternateIconName)
state = state.copy(\.appIcon, to: AppIcon(rawValue: icon.alternateIconName))
}
// MARK: - Private
private func update<Value>(setting: WritableKeyPath<AppSettingsDiff, Value>, value: Value) async {
@@ -84,7 +97,6 @@ final class AppSettingsStateStore: StateStore, Sendable {
await refreshStoredAppSettings()
}
@MainActor
private func refreshDeviceSettings() async {
let areNotificationsEnabled = await areNotificationsEnabled()
state =
@@ -93,7 +105,6 @@ final class AppSettingsStateStore: StateStore, Sendable {
.copy(\.appLanguage, to: appLangaugeProvider.appLangauge)
}
@MainActor
private func refreshStoredAppSettings() async {
do {
let settings = try await appSettingsRepository.getAppSettings().get()
@@ -131,7 +142,6 @@ final class AppSettingsStateStore: StateStore, Sendable {
}
}
@MainActor
private func openNativeAppSettings() async {
await urlOpener.open(.settings, options: [:])
}
@@ -31,7 +31,6 @@ class AutoLockStore: StateStore {
self.router = router
}
@MainActor
func handle(action: AutoLockAction) async {
switch action {
case .optionSelected(let autoLock):
@@ -129,7 +129,9 @@ private extension ToolbarWithActions {
switch self {
case .list:
L10n.Settings.CustomizeToolbars.listToolbarSectionTitle
case .message, .conversation:
case .message:
L10n.Settings.CustomizeToolbars.messageToolbarSectionTitle
case .conversation:
L10n.Settings.CustomizeToolbars.conversationToolbarSectionTitle
}
}
@@ -20,7 +20,6 @@ import Combine
import InboxCoreUI
import InboxCore
@MainActor
class CustomizeToolbarsStore: StateStore {
@Published var state: CustomizeToolbarState
private let customizeToolbarRepository: CustomizeToolbarRepository
@@ -19,7 +19,6 @@ import Combine
import InboxCore
import InboxCoreUI
@MainActor
class EditToolbarStore: StateStore {
@Published var state: EditToolbarState
private let customizeToolbarRepository: CustomizeToolbarRepository
@@ -19,7 +19,6 @@ import SwiftUI
import InboxCore
import InboxCoreUI
@MainActor
class PINStateStore: StateStore {
@Published var state: PINScreenState
private let pinScreenValidator: PINValidator
@@ -67,7 +67,6 @@ final class MessageBodyStateStore: StateStore {
self.backOnlineActionExecutor = backOnlineActionExecutor
}
@MainActor
func handle(action: Action) async {
switch action {
case .onLoad:
@@ -117,7 +116,6 @@ final class MessageBodyStateStore: StateStore {
// MARK: - Private
@MainActor
private func loadMessageBody(with options: TransformOpts) async {
switch await provider.messageBody(forMessageID: messageID, with: options) {
case .success(let body):
@@ -130,7 +128,6 @@ final class MessageBodyStateStore: StateStore {
}
}
@MainActor
private func reloadContentWhenBackOnline(options: TransformOpts) {
backOnlineActionExecutor.execute { [weak self] in
guard let self else { return }
@@ -139,7 +136,6 @@ final class MessageBodyStateStore: StateStore {
}
}
@MainActor
private func markAsLegitimate(with options: TransformOpts) async {
await executeAndReloadMessage(
operation: { await legitMessageMarker.markAsLegitimate(forMessageID: messageID) },
@@ -147,7 +143,6 @@ final class MessageBodyStateStore: StateStore {
)
}
@MainActor
private func unblockSender(emailAddress: String, with options: TransformOpts) async {
await executeAndReloadMessage(
operation: { await senderUnblocker.unblock(emailAddress: emailAddress) },
@@ -155,7 +150,6 @@ final class MessageBodyStateStore: StateStore {
)
}
@MainActor
private func unsubscribeNewsletter(with newsletterService: UnsubscribeNewsletter, options: TransformOpts) async {
await executeAndReloadMessage(
operation: { await newsletterService.unsubscribeFromNewsletter() },
@@ -164,7 +158,6 @@ final class MessageBodyStateStore: StateStore {
)
}
@MainActor
private func executeAndReloadMessage(
operation: () async -> VoidActionResult,
with options: TransformOpts,
@@ -16,6 +16,7 @@
// along with Proton Mail. If not, see https://www.gnu.org/licenses/.
import OrderedCollections
import InboxCore
import InboxCoreUI
import InboxDesignSystem
import InboxRSVP
@@ -31,6 +32,7 @@ struct MessageBodyView: View {
let mailbox: Mailbox
let editScheduledMessage: () -> Void
let unsnoozeConversation: () -> Void
let draftPresenter: RecipientDraftPresenter
@Binding var isBodyLoaded: Bool
@Binding var attachmentIDToOpen: ID?
@State var bodyContentHeight: CGFloat = .zero
@@ -43,7 +45,8 @@ struct MessageBodyView: View {
isBodyLoaded: Binding<Bool>,
attachmentIDToOpen: Binding<ID?>,
editScheduledMessage: @escaping () -> Void,
unsnoozeConversation: @escaping () -> Void
unsnoozeConversation: @escaping () -> Void,
draftPresenter: RecipientDraftPresenter
) {
self.messageID = messageID
self.emailAddress = emailAddress
@@ -53,6 +56,7 @@ struct MessageBodyView: View {
self._attachmentIDToOpen = attachmentIDToOpen
self.editScheduledMessage = editScheduledMessage
self.unsnoozeConversation = unsnoozeConversation
self.draftPresenter = draftPresenter
}
var body: some View {
@@ -67,7 +71,7 @@ struct MessageBodyView: View {
) { state, store in
VStack(spacing: .zero) {
if case .loaded(let body) = state.body, let rsvpServiceProvider = body.rsvpServiceProvider {
RSVPView(serviceProvider: rsvpServiceProvider)
RSVPView(serviceProvider: rsvpServiceProvider, draftPresenter: draftPresenter)
}
if case .loaded(let body) = state.body, !body.banners.isEmpty {
MessageBannersView(
@@ -16,14 +16,18 @@
// along with Proton Mail. If not, see https://www.gnu.org/licenses/.
import Collections
import InboxCore
import InboxCoreUI
import InboxDesignSystem
import proton_app_uniffi
import SwiftUI
import InboxCoreUI
struct ExpandedMessageCell: View {
private let mailbox: Mailbox
private let mailUserSession: MailUserSession
private let uiModel: ExpandedMessageCellUIModel
private let draftPresenter: RecipientDraftPresenter
private let messageAppearanceOverrideStore: MessageAppearanceOverrideStore
private let onEvent: (ExpandedMessageCellEvent) -> Void
private let htmlLoaded: () -> Void
private let areActionsHidden: Bool
@@ -37,14 +41,20 @@ struct ExpandedMessageCell: View {
init(
mailbox: Mailbox,
mailUserSession: MailUserSession,
uiModel: ExpandedMessageCellUIModel,
draftPresenter: RecipientDraftPresenter,
messageAppearanceOverrideStore: MessageAppearanceOverrideStore,
areActionsHidden: Bool,
attachmentIDToOpen: Binding<ID?>,
onEvent: @escaping (ExpandedMessageCellEvent) -> Void,
htmlLoaded: @escaping () -> Void
htmlLoaded: @escaping () -> Void,
) {
self.mailbox = mailbox
self.mailUserSession = mailUserSession
self.uiModel = uiModel
self.draftPresenter = draftPresenter
self.messageAppearanceOverrideStore = messageAppearanceOverrideStore
self.areActionsHidden = areActionsHidden
self._attachmentIDToOpen = attachmentIDToOpen
self.onEvent = onEvent
@@ -55,6 +65,9 @@ struct ExpandedMessageCell: View {
VStack(spacing: .zero) {
MessageDetailsView(
uiModel: uiModel.messageDetails,
mailbox: mailbox,
mailUserSession: mailUserSession,
messageAppearanceOverrideStore: messageAppearanceOverrideStore,
actionButtonsState: actionButtonsState,
onEvent: { event in
switch event {
@@ -64,12 +77,14 @@ struct ExpandedMessageCell: View {
onEvent(.onReply)
case .onReplyAll:
onEvent(.onReplyAll)
case .onMoreActions:
onEvent(.onMoreActions)
case .onMessageAction(let action):
onEvent(.onMessageAction(action))
case .onSenderTap:
onEvent(.onSenderTap)
case .onRecipientTap(let recipient):
onEvent(.onRecipientTap(recipient))
case .onEditToolbar:
onEvent(.onEditToolbar)
}
}
)
@@ -81,7 +96,8 @@ struct ExpandedMessageCell: View {
isBodyLoaded: $isBodyLoaded,
attachmentIDToOpen: $attachmentIDToOpen,
editScheduledMessage: { onEvent(.onEditScheduledMessage) },
unsnoozeConversation: { onEvent(.unsnoozeConversation) }
unsnoozeConversation: { onEvent(.unsnoozeConversation) },
draftPresenter: draftPresenter
)
if !areActionsHidden {
MessageActionButtonsView(
@@ -123,13 +139,15 @@ enum ExpandedMessageCellEvent {
case onReply
case onReplyAll
case onForward
case onMoreActions
case onSenderTap
case onRecipientTap(MessageDetail.Recipient)
case onEditScheduledMessage
case unsnoozeConversation
case onEditToolbar
case onMessageAction(MessageAction)
}
#Preview {
@@ -143,11 +161,14 @@ enum ExpandedMessageCellEvent {
return VStack(spacing: 0) {
ExpandedMessageCell(
mailbox: .dummy,
mailUserSession: .dummy,
uiModel: .init(
id: .init(value: 0),
unread: false,
messageDetails: messageDetails
),
draftPresenter: DraftPresenter.dummy(),
messageAppearanceOverrideStore: .init(),
areActionsHidden: false,
attachmentIDToOpen: .constant(nil),
onEvent: { _ in },
@@ -155,11 +176,14 @@ enum ExpandedMessageCellEvent {
)
ExpandedMessageCell(
mailbox: .dummy,
mailUserSession: .dummy,
uiModel: .init(
id: .init(value: 1),
unread: false,
messageDetails: messageDetails
),
draftPresenter: DraftPresenter.dummy(),
messageAppearanceOverrideStore: .init(),
areActionsHidden: false,
attachmentIDToOpen: .constant(nil),
onEvent: { _ in },
@@ -17,8 +17,7 @@
import InboxCoreUI
import InboxDesignSystem
import enum proton_app_uniffi.ExclusiveLocation
import enum proton_app_uniffi.MessageBanner
import proton_app_uniffi
import SwiftUI
struct MessageDetailsView: View {
@@ -33,6 +32,9 @@ struct MessageDetailsView: View {
@State private(set) var isHeaderCollapsed: Bool = true
let uiModel: MessageDetailsUIModel
let mailbox: Mailbox
let mailUserSession: MailUserSession
let messageAppearanceOverrideStore: MessageAppearanceOverrideStore
let actionButtonsState: ActionButtonsState
let onEvent: (MessageDetailsEvent) -> Void
@@ -226,11 +228,25 @@ struct MessageDetailsView: View {
action: { onEvent(uiModel.isSingleRecipient ? .onReply : .onReplyAll) },
image: Image(symbol: uiModel.isSingleRecipient ? .reply : .replyAll)
)
headerActionButton(
action: { onEvent(.onMoreActions) },
image: DS.Icon.icThreeDotsHorizontal.image
)
.accessibilityIdentifier(MessageDetailsViewIdentifiers.threeDotsButton)
MessageActionsMenu(
state: .initial(messageID: uiModel.id, showEditToolbar: false),
mailbox: mailbox,
mailUserSession: mailUserSession,
messageAppearanceOverrideStore: messageAppearanceOverrideStore,
actionTapped: { action in onEvent(.onMessageAction(action)) },
editToolbarTapped: { onEvent(.onEditToolbar) }
) {
DS.Icon.icThreeDotsHorizontal.image
.square(size: 20)
.foregroundStyle(actionButtonsState.isDisabled ? DS.Color.Text.disabled : DS.Color.Text.weak)
.square(size: 36)
.overlay(
RoundedRectangle(cornerRadius: DS.Radius.mediumLarge)
.stroke(DS.Color.Border.norm, lineWidth: 1)
)
}
.disabled(actionButtonsState.isDisabled)
.id(uiModel)
}
.foregroundColor(DS.Color.Icon.weak)
}
@@ -334,17 +350,9 @@ struct MessageDetailsView: View {
}
private var labelRow: some View {
let capsules = uiModel.labels.map { label in
CapsuleView(
text: label.text.stringResource,
color: label.color,
style: .label
)
}
return HStack(alignment: .center, spacing: DS.Spacing.small) {
CapsuleCloudView(
subviews: capsules,
HStack(alignment: .center, spacing: DS.Spacing.small) {
BadgesView(
badges: uiModel.labels.map { label in Badge(text: label.text, color: label.color) }
)
Spacer()
}
@@ -379,7 +387,8 @@ private enum RecipientGroup {
}
}
struct MessageDetailsUIModel: Equatable {
struct MessageDetailsUIModel: Hashable {
let id: ID
let avatar: AvatarUIModel
let sender: MessageDetail.Sender
let isSenderProtonOfficial: Bool
@@ -408,7 +417,7 @@ extension MessageDetailsUIModel {
enum MessageDetail {
struct Sender: Equatable {
struct Sender: Hashable {
let name: String
let address: String
let encryptionInfo: String
@@ -421,11 +430,15 @@ enum MessageDetail {
let avatarInfo: AvatarInfo
}
struct Location: Equatable {
struct Location: Hashable {
let id: ID
let name: LocalizedStringResource
let icon: Image
let iconColor: Color?
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
}
@@ -433,7 +446,8 @@ enum MessageDetailsEvent {
case onTap
case onReply
case onReplyAll
case onMoreActions
case onMessageAction(MessageAction)
case onEditToolbar
case onSenderTap
case onRecipientTap(MessageDetail.Recipient)
}
@@ -455,9 +469,12 @@ extension Array where Element == MessageDetail.Recipient {
.init(labelId: .init(value: 3), text: "Summer trip", color: .pink),
])
return MessageDetailsView(
MessageDetailsView(
isHeaderCollapsed: false,
uiModel: model,
mailbox: .dummy,
mailUserSession: .dummy,
messageAppearanceOverrideStore: .init(),
actionButtonsState: .enabled,
onEvent: { _ in }
)
@@ -505,6 +522,7 @@ enum MessageDetailsPreviewProvider {
recipientsBcc: [MessageDetail.Recipient] = recipientsBcc
) -> MessageDetailsUIModel {
.init(
id: ID.random(),
avatar: .init(
info: .init(initials: "", color: DS.Color.Background.secondary),
type: .sender(params: .init())
@@ -25,30 +25,51 @@ extension View {
func conversationBottomToolbar(
actions: ConversationToolbarActions?,
mailbox: @escaping () -> Mailbox,
mailUserSession: MailUserSession,
messageAppearanceOverrideStore: MessageAppearanceOverrideStore,
editToolbarTapped: @escaping (ToolbarType) -> Void,
messageActionSelected: @escaping (MessageAction) -> Void,
conversationActionSelected: @escaping (ConversationAction) -> Void
) -> some View {
modifier(
ConversationToolbarModifier(
actions: actions,
mailbox: mailbox,
mailUserSession: mailUserSession,
messageAppearanceOverrideStore: messageAppearanceOverrideStore,
editToolbarTapped: editToolbarTapped,
messageActionSelected: messageActionSelected,
conversationActionSelected: conversationActionSelected
))
)
)
}
}
struct ConversationToolbarModifier: ViewModifier {
private let actions: ConversationToolbarActions?
private let mailbox: () -> Mailbox
private let mailUserSession: MailUserSession
private let messageAppearanceOverrideStore: MessageAppearanceOverrideStore
private let editToolbarTapped: (ToolbarType) -> Void
private let messageActionSelected: (MessageAction) -> Void
private let conversationActionSelected: (ConversationAction) -> Void
init(
actions: ConversationToolbarActions?,
mailbox: @escaping () -> Mailbox,
mailUserSession: MailUserSession,
messageAppearanceOverrideStore: MessageAppearanceOverrideStore,
editToolbarTapped: @escaping (ToolbarType) -> Void,
messageActionSelected: @escaping (MessageAction) -> Void,
conversationActionSelected: @escaping (ConversationAction) -> Void
) {
self.actions = actions
self.mailbox = mailbox
self.mailUserSession = mailUserSession
self.messageAppearanceOverrideStore = messageAppearanceOverrideStore
self.editToolbarTapped = editToolbarTapped
self.messageActionSelected = messageActionSelected
self.conversationActionSelected = conversationActionSelected
}
@@ -59,29 +80,72 @@ struct ConversationToolbarModifier: ViewModifier {
ToolbarItemGroup(placement: .bottomBar) {
if let actions {
switch actions {
case .message(let actions):
toolbarContent(actions: actions.visibleMessageActions, selected: messageActionSelected)
case .conversation(let actions):
toolbarContent(actions: actions.visibleListActions, selected: conversationActionSelected)
case .message(let actions, let messageID):
messageToolbarContent(
messageID: messageID,
actions: actions.visibleMessageActions,
)
.id(actions)
case .conversation(let actions, let conversationID):
conversationToolbarContent(
conversationID: conversationID,
actions: actions.visibleListActions
)
.id(actions)
}
}
}
}
}
@ViewBuilder
private func toolbarContent<Action: DisplayableAction>(
private func messageToolbarContent(messageID: ID, actions: [MessageAction]) -> some View {
toolbarContent(actions: actions, selected: messageActionSelected) {
MessageActionsMenu(
state: .initial(messageID: messageID, showEditToolbar: true),
mailbox: mailbox(),
mailUserSession: mailUserSession,
messageAppearanceOverrideStore: messageAppearanceOverrideStore,
actionTapped: messageActionSelected,
editToolbarTapped: { editToolbarTapped(.message) }
) {
InternalAction.more.displayData.image
.foregroundStyle(DS.Color.Icon.weak)
}
}
}
private func conversationToolbarContent(conversationID: ID, actions: [ConversationAction]) -> some View {
toolbarContent(actions: actions, selected: conversationActionSelected) {
ConversationActionsMenu(
conversationID: conversationID,
mailbox: mailbox(),
mailUserSession: mailUserSession,
actionTapped: conversationActionSelected,
editToolbarTapped: { editToolbarTapped(.conversation) }
) {
InternalAction.more.displayData.image
.foregroundStyle(DS.Color.Icon.weak)
}
}
}
private func toolbarContent<MoreActionsMenu: View, Action: DisplayableAction>(
actions: [Action],
selected: @escaping (Action) -> Void
selected: @escaping (Action) -> Void,
moreActionsMenu: @escaping () -> MoreActionsMenu
) -> some View {
HStack(alignment: .center) {
ForEachEnumerated(actions, id: \.offset) { action, index in
if index == 0 {
Spacer()
}
Button(action: { selected(action) }) {
action.displayData.image
.foregroundStyle(DS.Color.Icon.weak)
if action.isMoreAction {
moreActionsMenu()
} else {
Button(action: { selected(action) }) {
action.displayData.image
.foregroundStyle(DS.Color.Icon.weak)
}
}
Spacer()
}
@@ -26,6 +26,7 @@ struct ConversationDetailListView: View {
@ObservedObject private var model: ConversationDetailModel
private let mailUserSession: MailUserSession
private let draftPresenter: RecipientDraftPresenter
private let editToolbar: () -> Void
private let goBack: () -> Void
/// These attributes trigger the different action sheets
@@ -36,11 +37,13 @@ struct ConversationDetailListView: View {
model: ConversationDetailModel,
mailUserSession: MailUserSession,
draftPresenter: RecipientDraftPresenter,
editToolbar: @escaping () -> Void,
goBack: @escaping () -> Void
) {
self.model = model
self.mailUserSession = mailUserSession
self.draftPresenter = draftPresenter
self.editToolbar = editToolbar
self.goBack = goBack
}
@@ -146,7 +149,10 @@ struct ConversationDetailListView: View {
private func expandedMessageCell(uiModel: ExpandedMessageCellUIModel) -> some View {
ExpandedMessageCell(
mailbox: model.mailbox.unsafelyUnwrapped,
mailUserSession: mailUserSession,
uiModel: uiModel,
draftPresenter: draftPresenter,
messageAppearanceOverrideStore: model.messageAppearanceOverrideStore,
areActionsHidden: model.areActionsHidden,
attachmentIDToOpen: $model.attachmentIDToOpen,
onEvent: { onExpandedMessageCellEvent($0, uiModel: uiModel) },
@@ -165,9 +171,17 @@ struct ConversationDetailListView: View {
model.onReplyAllMessage(withId: uiModel.id, toastStateStore: toastStateStore)
case .onForward:
model.onForwardMessage(withId: uiModel.id, toastStateStore: toastStateStore)
case .onMoreActions:
let input = MessageActionsSheetInput(id: uiModel.id, title: model.seed.subject, origin: .messageHeader)
model.actionSheets = model.actionSheets.copy(\.message, to: input)
case .onEditToolbar:
editToolbar()
case .onMessageAction(let action):
Task {
await model.handle(
action: action,
messageID: uiModel.id,
toastStateStore: toastStateStore,
goBack: goBack
)
}
case .onSenderTap:
senderActionTarget = uiModel
case .onRecipientTap(let recipient):
@@ -56,7 +56,11 @@ final class ConversationDetailModel: Sendable, ObservableObject {
}
var isBottomBarHidden: Bool {
seed.isOutbox || conversationToolbarActions?.isEmpty == true
if let conversationToolbarActions {
seed.isOutbox || conversationToolbarActions.isEmpty
} else {
true
}
}
var areActionsHidden: Bool {
@@ -252,7 +256,6 @@ final class ConversationDetailModel: Sendable, ObservableObject {
action: MessageAction,
messageID: ID,
toastStateStore: ToastStateStore,
actionOrigin: ConversationActionOrigin,
goBack: @MainActor @escaping () -> Void
) async {
switch action {
@@ -317,7 +320,7 @@ final class ConversationDetailModel: Sendable, ObservableObject {
)
}
)
present(alert: alert, origin: actionOrigin)
actionAlert = alert
case .reply:
actionSheets = .allSheetsDismissed
onReplyMessage(withId: messageID, toastStateStore: toastStateStore)
@@ -354,9 +357,9 @@ final class ConversationDetailModel: Sendable, ObservableObject {
goBack: goBack
)
})
present(alert: alert, origin: actionOrigin)
actionAlert = alert
case .more:
actionSheets = .allSheetsDismissed.copy(\.message, to: .init(id: messageID, title: seed.subject, origin: .toolbar))
break
}
}
@@ -364,7 +367,6 @@ final class ConversationDetailModel: Sendable, ObservableObject {
func handle(
action: ConversationAction,
toastStateStore: ToastStateStore,
actionOrigin: ConversationActionOrigin,
goBack: @MainActor @escaping () -> Void
) async {
guard let conversationItem, conversationItem.itemType == .conversation else { return }
@@ -380,8 +382,6 @@ final class ConversationDetailModel: Sendable, ObservableObject {
case .labelAs:
actionSheets = .allSheetsDismissed
.copy(\.labelAs, to: .init(sheetType: .labelAs, ids: [conversationID], mailboxItem: .conversation))
case .more:
actionSheets = actionSheets.copy(\.conversation, to: .init(id: conversationID, title: seed.subject))
case .moveTo:
actionSheets = .allSheetsDismissed
.copy(\.moveTo, to: .init(sheetType: .moveTo, ids: [conversationID], mailboxItem: .conversation))
@@ -406,7 +406,7 @@ final class ConversationDetailModel: Sendable, ObservableObject {
)
}
)
present(alert: alert, origin: actionOrigin)
actionAlert = alert
case .star:
await starActionPerformer.star(itemsWithIDs: [conversationID], itemType: .conversation)
actionSheets = .allSheetsDismissed
@@ -415,6 +415,8 @@ final class ConversationDetailModel: Sendable, ObservableObject {
actionSheets = .allSheetsDismissed
case .snooze:
actionSheets = .allSheetsDismissed.copy(\.snooze, to: conversationID)
case .more:
break
}
}
@@ -451,20 +453,9 @@ final class ConversationDetailModel: Sendable, ObservableObject {
.markAsUnread(itemsWithIDs: [id], itemType: itemType)
}
@MainActor
private func present(alert: AlertModel, origin: ConversationActionOrigin) {
switch origin {
case .sheet:
actionSheets.alert = alert
case .toolbar:
actionAlert = alert
}
}
@MainActor
private func hideAlert() {
actionAlert = nil
actionSheets.alert = nil
}
@MainActor
@@ -781,11 +772,11 @@ extension ConversationDetailModel {
private func onReplyAction(messageId: ID, action: ReplyAction, toastStateStore: ToastStateStore) {
Task {
await draftPresenter.handleReplyAction(
for: messageId, action: action,
onError: { error in
toastStateStore.present(toast: .error(message: error.localizedDescription))
})
do {
try await draftPresenter.handleReplyAction(for: messageId, action: action)
} catch {
toastStateStore.present(toast: .error(message: error.localizedDescription))
}
}
}
@@ -820,7 +811,7 @@ extension ConversationDetailModel {
mailbox: mailbox,
conversationId: conversationItem.id
).get()
self.conversationToolbarActions = .conversation(actions: actions)
self.conversationToolbarActions = .conversation(actions: actions, conversationID: conversationItem.id)
} catch {
AppLogger.log(error: error, category: .conversationDetail)
}
@@ -835,7 +826,7 @@ extension ConversationDetailModel {
theme: theme,
messageId: conversationItem.id
).get()
self.conversationToolbarActions = .message(actions: actions)
self.conversationToolbarActions = .message(actions: actions, messageID: conversationItem.id)
} catch {
AppLogger.log(error: error, category: .conversationDetail)
}
@@ -907,7 +898,7 @@ enum ConversationModelError: Error {
private extension MailboxActionSheetsState {
static func initial() -> Self {
.init(message: nil, labelAs: nil, moveTo: nil)
.init(labelAs: nil, moveTo: nil, editToolbar: nil)
}
}
@@ -55,14 +55,17 @@ struct ConversationDetailScreen: View {
conversationView
.conversationBottomToolbar(
actions: model.conversationToolbarActions,
mailbox: { model.mailbox.unsafelyUnwrapped },
mailUserSession: mailUserSession,
messageAppearanceOverrideStore: model.messageAppearanceOverrideStore,
editToolbarTapped: { toolbarType in model.actionSheets.editToolbar = toolbarType },
messageActionSelected: { action in
if let messageID = model.state.singleMessageIDInMessageMode {
Task {
await model.handle(
action: action,
messageID: messageID,
toastStateStore: toastStateStore,
actionOrigin: .toolbar,
toastStateStore: toastStateStore
) {
goBackToMailbox()
}
@@ -71,7 +74,7 @@ struct ConversationDetailScreen: View {
},
conversationActionSelected: { action in
Task {
await model.handle(action: action, toastStateStore: toastStateStore, actionOrigin: .toolbar) {
await model.handle(action: action, toastStateStore: toastStateStore) {
goBackToMailbox()
}
}
@@ -91,31 +94,16 @@ struct ConversationDetailScreen: View {
mailbox: { model.mailbox.unsafelyUnwrapped },
mailUserSession: mailUserSession,
state: $model.actionSheets,
messageActionTapped: { action, id in
Task {
await model.handle(
action: action,
messageID: id,
toastStateStore: toastStateStore,
actionOrigin: .sheet
) {
goBackToMailbox()
}
}
},
conversationActionTapped: { action in
Task {
await model.handle(
action: action,
toastStateStore: toastStateStore,
actionOrigin: .sheet
) {
goBackToMailbox()
}
}
},
goBackNavigation: { goBackToMailbox() },
messageAppearanceOverrideStore: model.messageAppearanceOverrideStore
goBackNavigation: { goBackToMailbox() }
)
.sheet(
item: $model.actionSheets.editToolbar,
content: { toolbarType in
EditToolbarScreen(
state: .initial(toolbarType: toolbarType),
customizeToolbarService: mailUserSession
)
}
)
.alert(model: $model.actionAlert)
.fullScreenCover(item: $model.attachmentIDToOpen) { id in
@@ -156,6 +144,7 @@ struct ConversationDetailScreen: View {
model: model,
mailUserSession: mailUserSession,
draftPresenter: draftPresenter,
editToolbar: {},
goBack: { goBackToMailbox() }
)
}
@@ -18,17 +18,17 @@
import proton_app_uniffi
enum ConversationToolbarActions {
case message(actions: AllMessageActions)
case conversation(actions: AllConversationActions)
case message(actions: AllMessageActions, messageID: ID)
case conversation(actions: AllConversationActions, conversationID: ID)
}
extension ConversationToolbarActions {
var isEmpty: Bool {
switch self {
case .message(let actions):
case .message(let actions, _):
actions.visibleMessageActions.isEmpty
case .conversation(let actions):
case .conversation(let actions, _):
actions.visibleListActions.isEmpty
}
}
@@ -21,12 +21,10 @@ import proton_app_uniffi
import SwiftUI
struct MailboxActionSheetsState: Copying {
var message: MessageActionsSheetInput?
var conversation: ConversationActionsSheetInput?
var labelAs: ActionSheetInput?
var moveTo: ActionSheetInput?
var snooze: ID?
var alert: AlertModel?
var editToolbar: ToolbarType?
}
extension View {
@@ -35,20 +33,14 @@ extension View {
mailbox: @escaping () -> Mailbox,
mailUserSession: MailUserSession,
state: Binding<MailboxActionSheetsState>,
messageActionTapped: @escaping (MessageAction, ID) -> Void,
conversationActionTapped: @escaping (ConversationAction) -> Void,
goBackNavigation: (() -> Void)? = nil,
messageAppearanceOverrideStore: MessageAppearanceOverrideStore
goBackNavigation: (() -> Void)? = nil
) -> some View {
modifier(
MailboxActionSheets(
mailbox: mailbox,
mailUserSession: mailUserSession,
state: state,
messageActionTapped: messageActionTapped,
conversationActionTapped: conversationActionTapped,
goBackNavigation: goBackNavigation,
messageAppearanceOverrideStore: messageAppearanceOverrideStore
goBackNavigation: goBackNavigation
))
}
}
@@ -58,54 +50,21 @@ private struct MailboxActionSheets: ViewModifier {
private let mailbox: () -> Mailbox
private let mailUserSession: MailUserSession
private let goBackNavigation: (() -> Void)?
private let messageActionTapped: (MessageAction, ID) -> Void
private let conversationActionTapped: (ConversationAction) -> Void
private let messageAppearanceOverrideStore: MessageAppearanceOverrideStore
init(
mailbox: @escaping () -> Mailbox,
mailUserSession: MailUserSession,
state: Binding<MailboxActionSheetsState>,
messageActionTapped: @escaping (MessageAction, ID) -> Void,
conversationActionTapped: @escaping (ConversationAction) -> Void,
goBackNavigation: (() -> Void)?,
messageAppearanceOverrideStore: MessageAppearanceOverrideStore
goBackNavigation: (() -> Void)?
) {
self.mailbox = mailbox
self.mailUserSession = mailUserSession
self._state = state
self.conversationActionTapped = conversationActionTapped
self.messageActionTapped = messageActionTapped
self.goBackNavigation = goBackNavigation
self.messageAppearanceOverrideStore = messageAppearanceOverrideStore
}
func body(content: Content) -> some View {
content
.sheet(item: $state.message) { input in
MessageActionsSheet(
state: .initial(
messageID: input.id,
title: input.title,
isEditToolbarVisible: input.origin.isEditToolbarVisible
),
mailbox: mailbox(),
mailUserSession: mailUserSession,
messageAppearanceOverrideStore: messageAppearanceOverrideStore,
actionTapped: { messageActionTapped($0, input.id) }
)
.alert(model: $state.alert)
}
.sheet(item: $state.conversation) { input in
ConversationActionsSheet(
conversationID: input.id,
title: input.title,
mailbox: mailbox(),
mailUserSession: mailUserSession,
actionTapped: { conversationActionTapped($0) }
)
.alert(model: $state.alert)
}
.sheet(item: snoozeBinding) { conversationID in
SnoozeView(
state: .initial(
@@ -234,10 +234,22 @@ struct HomeScreen: View {
}
private func handleDeepLink(_ deepLink: URL) {
guard isCurrentSessionActive() else {
return
}
if let route = DeepLinkRouteCoder.decode(deepLink: deepLink) {
modalState = nil
appUIStateStore.toggleSidebar(isOpen: false)
appRoute.updateRoute(to: route)
}
}
private func isCurrentSessionActive() -> Bool {
guard let activeSession = appContext.sessionState.userSession else {
return false
}
return ObjectIdentifier(activeSession) == ObjectIdentifier(userSession)
}
}
@@ -22,7 +22,7 @@ struct AvatarInfo: Hashable {
let color: Color
}
struct AvatarUIModel: Equatable {
struct AvatarUIModel: Hashable {
let info: AvatarInfo
let type: AvatarViewType
@@ -28,7 +28,6 @@ final class ReportProblemStateStore: StateStore {
private let issueReportBuilder: IssueReportBuilder
private let dismiss: () -> Void
@MainActor
init(
state: ReportProblemState,
reportProblemService: ReportProblemService,
@@ -45,7 +44,6 @@ final class ReportProblemStateStore: StateStore {
self.dismiss = dismiss
}
@MainActor
func handle(action: ReportProblemAction) async {
switch action {
case .textEntered:
@@ -110,12 +108,10 @@ final class ReportProblemStateStore: StateStore {
}
}
@MainActor
private var isFormEmpty: Bool {
[state.summary, state.actualResults, state.expectedResults, state.stepsToReproduce].allSatisfy(\.isEmpty)
}
@MainActor
private var issueReport: IssueReport {
issueReportBuilder.build(
with: .init(
@@ -23,11 +23,17 @@ import SwiftUI
struct MobileSignatureScreen: View {
@EnvironmentObject private var toastStateStore: ToastStateStore
let customSettings: CustomSettingsProtocol
private let customSettings: CustomSettingsProtocol
private let initialState: MobileSignatureState
init(customSettings: CustomSettingsProtocol, initialState: MobileSignatureState = .initial) {
self.customSettings = customSettings
self.initialState = initialState
}
var body: some View {
StoreView(
store: MobileSignatureStateStore(customSettings: customSettings)
store: MobileSignatureStateStore(customSettings: customSettings, initialState: initialState)
) { state, store in
ZStack {
DS.Color.BackgroundInverted.norm
@@ -85,8 +91,6 @@ struct MobileSignatureScreen: View {
}
#Preview {
let toastStateStore = ToastStateStore(initialState: .initial)
NavigationStack {
MobileSignatureScreen(customSettings: CustomSettingsPreviewProvider(status: .enabled))
}
@@ -22,10 +22,6 @@ import proton_app_uniffi
struct MobileSignatureState: Copying, Equatable {
var mobileSignature: MobileSignature
var toast: Toast?
var textBoxOpacity: Double {
mobileSignature.status.isEnabled ? 1 : 0
}
}
extension MobileSignatureStatus {
@@ -20,7 +20,6 @@ import InboxCore
import InboxCoreUI
import proton_app_uniffi
@MainActor
final class MobileSignatureStateStore: StateStore {
@Published var state: MobileSignatureState = .initial
@@ -33,9 +32,14 @@ final class MobileSignatureStateStore: StateStore {
}
}
init(customSettings: CustomSettingsProtocol, clock: any Clock<Duration> = ContinuousClock()) {
init(
customSettings: CustomSettingsProtocol,
clock: any Clock<Duration> = ContinuousClock(),
initialState: MobileSignatureState = .initial
) {
self.customSettings = customSettings
self.clock = clock
state = initialState
}
func handle(action: MobileSignatureStateStoreAction) async {
@@ -97,7 +97,7 @@ struct SidebarScreen: View {
separator
}.background(
GeometryReader { geometry in
TransparentBlur()
BlurredBackground(fallbackBackgroundColor: DS.Color.Sidebar.background)
.edgesIgnoringSafeArea(.all)
.preference(key: HeightPreferenceKey.self, value: geometry.size.height)
.onPreferenceChange(HeightPreferenceKey.self) { value in
@@ -18,7 +18,7 @@
import InboxDesignSystem
import SwiftUI
enum AvatarViewType: Equatable {
enum AvatarViewType: Hashable {
case sender(params: SenderImageDataParameters)
case other
@@ -1,89 +0,0 @@
// Copyright (c) 2024 Proton Technologies AG
//
// This file is part of Proton Mail.
//
// Proton Mail 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.
//
// Proton Mail 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 Proton Mail. If not, see https://www.gnu.org/licenses/.
import InboxDesignSystem
import SwiftUI
struct CapsuleView: View {
let text: LocalizedStringResource
let color: Color
let icon: Image?
let iconColor: Color?
let style: CapsuleStyle
init(
text: LocalizedStringResource,
color: Color,
icon: Image? = nil,
iconColor: Color? = nil,
style: CapsuleStyle
) {
self.text = text
self.color = color
self.icon = icon
self.iconColor = iconColor
self.style = style
}
var body: some View {
HStack(spacing: DS.Spacing.small) {
if let icon {
icon
.resizable()
.aspectRatio(contentMode: .fit)
.square(size: 14)
.foregroundColor(iconColor ?? DS.Color.Icon.weak)
}
Text(verbatim: text.string)
.font(.caption2)
.fontWeight(style.fontWeight)
.foregroundColor(style.fontColor)
.lineLimit(1)
.frame(minWidth: 30)
}
.padding(EdgeInsets(top: DS.Spacing.small, leading: DS.Spacing.standard, bottom: DS.Spacing.small, trailing: DS.Spacing.standard))
.background(
RoundedRectangle(cornerRadius: DS.Radius.medium)
.foregroundColor(color)
)
}
}
struct CapsuleStyle {
let fontColor: Color
let fontWeight: Font.Weight
static let attachment: CapsuleStyle = {
.init(fontColor: DS.Color.Text.norm, fontWeight: .regular)
}()
static let label: CapsuleStyle = {
.init(fontColor: .white, fontWeight: .semibold)
}()
}
#Preview {
VStack {
CapsuleView(text: "".notLocalized.stringResource, color: DS.Color.Background.secondary, style: .attachment)
CapsuleView(text: "2 files".notLocalized.stringResource, color: DS.Color.Background.secondary, icon: Image(DS.Icon.icPaperClip), style: .attachment)
CapsuleView(text: "games".notLocalized.stringResource, color: DS.Color.Background.secondary, icon: Image(systemName: "gamecontroller"), style: .attachment)
CapsuleView(text: "Work".notLocalized.stringResource, color: .blue, style: .label)
CapsuleView(text: "Friends & Fam".notLocalized.stringResource, color: .pink, style: .label)
}
}
@@ -59,7 +59,6 @@ final class EmptyFolderBannerStateStore: StateStore {
// MARK: - StateStore
@MainActor
func handle(action: Action) async {
switch action {
case .upgradeToAutoDelete:
@@ -61,7 +61,7 @@ struct MailboxItemsListView<EmptyView: View>: View {
.toolbar(selectionState.hasItems ? .visible : .hidden, for: .bottomBar)
.animation(.default, value: selectionState.hasItems)
.listActionsToolbar(
state: .initial,
initialState: .initial,
availableActions: .productionInstance,
itemTypeForActionBar: config.itemTypeForActionBar,
mailUserSession: mailUserSession,
@@ -24,6 +24,7 @@ import SwiftUI
struct MessageAddressActionView: View {
@EnvironmentObject var toastStateStore: ToastStateStore
@Environment(\.openURL) var openURL
@Environment(\.pasteboard) var pasteboard
@Environment(\.dismissTestable) var dismiss
let avatarUIModel: AvatarUIModel
let name: String
@@ -40,7 +41,7 @@ struct MessageAddressActionView: View {
phoneNumber: .none,
session: mailUserSession,
toastStateStore: toastStateStore,
pasteboard: .general,
pasteboard: pasteboard,
openURL: openURL,
blockAddress: blockAddress(session:email:),
draftPresenter: draftPresenter,
@@ -70,7 +70,6 @@ final class MessageAddressActionViewStateStore: StateStore {
// MARK: - Public
@MainActor
func handle(action: Action) async {
switch action {
case .onTap(let tapAction):
@@ -82,7 +81,6 @@ final class MessageAddressActionViewStateStore: StateStore {
// MARK: - Private
@MainActor
private func handleTap(action: MessageAddressAction) async {
switch action {
case .newMessage:
@@ -97,9 +95,9 @@ final class MessageAddressActionViewStateStore: StateStore {
openURL(url)
}
case .copyAddress:
clipboard.copyToClipboard(value: state.email, forName: L10n.Action.Clipboard.emailAddress)
clipboard.copyToClipboard(value: state.email, forName: CommonL10n.Clipboard.emailAddress)
case .copyName:
clipboard.copyToClipboard(value: state.name, forName: L10n.Action.Clipboard.name)
clipboard.copyToClipboard(value: state.name, forName: CommonL10n.Clipboard.name)
case .addToContacts:
toastStateStore.present(toast: .comingSoon)
case .blockContact:
@@ -107,7 +105,6 @@ final class MessageAddressActionViewStateStore: StateStore {
}
}
@MainActor
private func handleAlert(action: BlockAddressAlertAction) async {
switch action {
case .cancel:
@@ -35,7 +35,6 @@ struct MessageBannersView: View {
case unsubscribeNewsletterTapped
}
@EnvironmentObject var toastStateStore: ToastStateStore
let types: OrderedSet<MessageBanner>
let timerPublisher: Publishers.Autoconnect<Timer.TimerPublisher>
let scheduleSendDateFormatter: ScheduleSendDateFormatter
@@ -137,7 +137,7 @@ class RecurringBackgroundTaskScheduler: @unchecked Sendable {
}
}
private func checkForSessionSetUpToComplete(completion: @Sendable @escaping () -> Void) {
private func checkForSessionSetUpToComplete(completion: @escaping () -> Void) {
sessionSetUpCheckCancellable =
Publishers
.CombineLatest(sessionStatePublisher, timerFactory(0.5))
+33 -13
View File
@@ -48,17 +48,6 @@ enum L10n {
)
}
enum Clipboard {
static let emailAddress = LocalizedStringResource(
"email address",
comment: "Label for an email address when copying it to the clipboard."
)
static let name = LocalizedStringResource(
"name",
comment: "Label for a person's name when copying it to the clipboard."
)
}
enum Print {
static let error = LocalizedStringResource(
"Could not print requested e-mail",
@@ -214,6 +203,10 @@ enum L10n {
"Reply all",
comment: "Action title for replying to a sender and all receipients of given message in the action sheet."
)
static let moreOptions = LocalizedStringResource(
"More options",
comment: "A button title that shows rest of available actions in the menu"
)
enum Delete {
enum Alert {
@@ -879,6 +872,29 @@ enum L10n {
}
enum Settings {
enum AppIcon {
static let buttonTitle = LocalizedStringResource(
"App Icon",
comment: "Title of the button that allows the user to change the apps icon."
)
static let primary = LocalizedStringResource(
"Primary",
comment: "Name of the default (primary) app icon shown in the app icon picker."
)
static let notes = LocalizedStringResource(
"Notes",
comment: "Name of the alternate 'Notes' app icon shown in the app icon picker."
)
static let weather = LocalizedStringResource(
"Weather",
comment: "Name of the alternate 'Weather' app icon shown in the app icon picker."
)
static let calculator = LocalizedStringResource(
"Calculator",
comment: "Name of the alternate 'Calculator' app icon shown in the app icon picker."
)
}
enum App {
static let title = LocalizedStringResource(
"App customizations",
@@ -1043,15 +1059,19 @@ enum L10n {
comment: "Title of a section displaying selected list toolbar actions."
)
static let listToolbarSectionFooter = LocalizedStringResource(
"This toolbar appears when multiple messages are selected in the message list (e.g., Inbox, Trash, etc.).",
"This toolbar appears when multiple messages are selected in a list view.",
comment: "Footer of a section displaying selected list toolbar actions."
)
static let messageToolbarSectionTitle = LocalizedStringResource(
"Message toolbar",
comment: "Title of a section displaying selected message toolbar actions."
)
static let conversationToolbarSectionTitle = LocalizedStringResource(
"Conversation toolbar",
comment: "Title of a section displaying selected conversation toolbar actions."
)
static let conversationToolbarSectionFooter = LocalizedStringResource(
"This toolbar remains consistently visible during message reading.",
"This toolbar remains visible when a message is open.",
comment: "Footer of a section displaying selected conversation toolbar actions."
)
static let editActions = LocalizedStringResource(
@@ -70,6 +70,7 @@ extension Message {
id: id,
unread: unread,
messageDetails: MessageDetailsUIModel(
id: id,
avatar: sender.senderAvatar,
sender: .init(
name: sender.uiRepresentation,
@@ -15,22 +15,22 @@
// You should have received a copy of the GNU General Public License
// along with Proton Mail. If not, see https://www.gnu.org/licenses/.
struct MessageActionsSheetInput: Identifiable {
enum Origin {
case toolbar
case messageHeader
@testable import ProtonMail
var isEditToolbarVisible: Bool {
switch self {
case .toolbar:
true
case .messageHeader:
false
}
}
class AppIconConfiguratorSpy: AppIconConfigurable {
var stubbedSupportsAlternateIcons: Bool = true
private(set) var setAlternateIconNameCalls: [String?] = []
// MARK: - AppIconConfigurable
var alternateIconName: String?
var supportsAlternateIcons: Bool {
stubbedSupportsAlternateIcons
}
let id: ID
let title: String
let origin: Origin
func setAlternateIconName(_ alternateIconName: String?) async throws {
setAlternateIconNameCalls.append(alternateIconName)
}
}

Some files were not shown because too many files have changed in this diff Show More