Merge remote-tracking branch 'origin/main' into release/7.6.2

This commit is contained in:
Jacek Krasiukianis
2025-12-22 10:16:17 +01:00
223 changed files with 2528 additions and 1680 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
{
"project": "apple-mail-new",
"locale": "9275202d5eed1f3c51f851d057448fb82680ccd3"
"locale": "741b7702a7b3001d3e2c26924f7349feaff51747"
}
+70 -2
View File
@@ -1,11 +1,79 @@
{
"fileScopedDeclarationPrivacy": {
"accessLevel": "private"
},
"indentBlankLines": false,
"indentConditionalCompilationBlocks": true,
"indentSwitchCaseLabels": false,
"indentation": {
"spaces": 4
},
"lineBreakAroundMultilineExpressionChainComponents": true,
"lineBreakBeforeControlFlowKeywords": false,
"lineBreakBeforeEachArgument": false,
"lineBreakBeforeEachGenericRequirement": false,
"lineBreakBetweenDeclarationAttributes": false,
"lineLength": 200,
"maximumBlankLines": 1,
"multiElementCollectionTrailingCommas": true,
"multilineTrailingCommaBehavior": "alwaysUsed",
"noAssignmentInExpressions": {
"allowedFunctions": [
"XCTAssertNoThrow"
]
},
"prioritizeKeepingFunctionOutputTogether": false,
"reflowMultilineStringLiterals": {
"never": {}
},
"respectsExistingLineBreaks": true,
"rules": {
"AllPublicDeclarationsHaveDocumentation": false,
"AlwaysUseLiteralForEmptyCollectionInit": true,
"AlwaysUseLowerCamelCase": true,
"AmbiguousTrailingClosureOverload": true,
"AvoidRetroactiveConformances": false,
"BeginDocumentationCommentWithOneLineSummary": true,
"DoNotUseSemicolons": true,
"DontRepeatTypeInStaticProperties": true,
"FileScopedDeclarationPrivacy": true,
"FullyIndirectEnum": true,
"GroupNumericLiterals": true,
"IdentifiersMustBeASCII": true,
"NeverForceUnwrap": false,
"NeverUseForceTry": false,
"NeverUseImplicitlyUnwrappedOptionals": false,
"NoAccessLevelOnExtensionDeclaration": false,
"NoAssignmentInExpressions": true,
"NoBlockComments": false,
"NoCasesWithOnlyFallthrough": true,
"NoEmptyLinesOpeningClosingBraces": true,
"NoEmptyTrailingClosureParentheses": true,
"NoLabelsInCasePatterns": true,
"NoLeadingUnderscores": false,
"NoParensAroundConditions": true,
"NoPlaygroundLiterals": true,
"NoVoidReturnOnFunctionSignature": true,
"OmitExplicitReturns": true,
"OneCasePerLine": true,
"OneVariableDeclarationPerLine": true,
"OnlyOneTrailingClosureArgument": true,
"OrderedImports": true,
"UseTripleSlashForDocumentationComments": true
}
"ReplaceForEachWithForLoop": true,
"ReturnVoidInsteadOfEmptyTuple": true,
"TypeNamesShouldBeCapitalized": true,
"UseEarlyExits": false,
"UseExplicitNilCheckInConditions": true,
"UseLetInEveryBoundCaseVariable": true,
"UseShorthandTypeNames": true,
"UseSingleLinePropertyGetter": true,
"UseSynthesizedInitializer": true,
"UseTripleSlashForDocumentationComments": true,
"UseWhereClausesInForLoops": true,
"ValidateDocumentationComments": true
},
"spacesAroundRangeFormationOperators": false,
"spacesBeforeEndOfLineComments": 2,
"tabWidth": 8,
"version": 1
}
-5
View File
@@ -2,8 +2,3 @@ source "https://rubygems.org"
gem "fastlane"
gem "fastlane-plugin-sentry"
# shouldn't be necessary, but it is, at least until Fastlane is updated: https://github.com/fastlane/fastlane/issues/29183#issuecomment-2567093826
gem "abbrev"
gem "mutex_m"
gem "ostruct"
+45 -40
View File
@@ -1,40 +1,42 @@
GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.7)
base64
nkf
rexml
CFPropertyList (3.0.8)
abbrev (0.1.2)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
addressable (2.8.8)
public_suffix (>= 2.0.2, < 8.0)
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.3.0)
aws-partitions (1.1035.0)
aws-sdk-core (3.215.0)
aws-eventstream (1.4.0)
aws-partitions (1.1194.0)
aws-sdk-core (3.239.2)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
base64
bigdecimal
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.96.0)
aws-sdk-core (~> 3, >= 3.210.0)
logger
aws-sdk-kms (1.118.0)
aws-sdk-core (~> 3, >= 3.239.1)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.177.0)
aws-sdk-core (~> 3, >= 3.210.0)
aws-sdk-s3 (1.206.0)
aws-sdk-core (~> 3, >= 3.234.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.11.0)
aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
base64 (0.2.0)
bigdecimal (3.3.1)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
commander (4.6.0)
highline (~> 2.0.0)
csv (3.3.5)
declarative (0.0.20)
digest-crc (0.6.5)
digest-crc (0.7.0)
rake (>= 12.0.0, < 14.0.0)
domain_name (0.6.20240107)
dotenv (2.8.1)
@@ -52,14 +54,14 @@ GEM
faraday-rack (~> 1.0)
faraday-retry (~> 1.0)
ruby2_keywords (>= 0.0.4)
faraday-cookie_jar (0.0.7)
faraday-cookie_jar (0.0.8)
faraday (>= 0.8.0)
http-cookie (~> 1.0.0)
http-cookie (>= 1.0.0)
faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.0)
faraday-em_synchrony (1.0.1)
faraday-excon (1.1.0)
faraday-httpclient (1.0.1)
faraday-multipart (1.1.0)
faraday-multipart (1.1.1)
multipart-post (~> 2.0)
faraday-net_http (1.0.2)
faraday-net_http_persistent (1.2.0)
@@ -69,15 +71,18 @@ GEM
faraday_middleware (1.2.1)
faraday (~> 1.0)
fastimage (2.4.0)
fastlane (2.226.0)
fastlane (2.229.1)
CFPropertyList (>= 2.3, < 4.0.0)
abbrev (~> 0.1.2)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
aws-sdk-s3 (~> 1.0)
babosa (>= 1.0.3, < 2.0.0)
base64 (~> 0.2.0)
bundler (>= 1.12.0, < 3.0.0)
colored (~> 1.2)
commander (~> 4.6)
csv (~> 3.3)
dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0)
excon (>= 0.71.0, < 1.0.0)
@@ -97,7 +102,9 @@ GEM
jwt (>= 2.1.0, < 3)
mini_magick (>= 4.9.4, < 5.0.0)
multipart-post (>= 2.0.0, < 3.0.0)
mutex_m (~> 0.3.0)
naturally (~> 2.2)
nkf (~> 0.2.0)
optparse (>= 0.1.1, < 1.0.0)
plist (>= 3.1.0, < 4.0.0)
rubyzip (>= 2.0.0, < 3.0.0)
@@ -109,9 +116,9 @@ GEM
tty-spinner (>= 0.8.0, < 1.0.0)
word_wrap (~> 1.0.0)
xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.4.0)
xcpretty (~> 0.4.1)
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
fastlane-plugin-sentry (1.30.0)
fastlane-plugin-sentry (1.36.0)
os (~> 1.1, >= 1.1.4)
fastlane-sirp (1.0.0)
sysrandom (~> 1.0)
@@ -132,12 +139,12 @@ GEM
google-apis-core (>= 0.11.0, < 2.a)
google-apis-storage_v1 (0.31.0)
google-apis-core (>= 0.11.0, < 2.a)
google-cloud-core (1.7.1)
google-cloud-core (1.8.0)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (1.6.0)
faraday (>= 0.17.3, < 3.0)
google-cloud-errors (1.4.0)
google-cloud-errors (1.5.0)
google-cloud-storage (1.47.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
@@ -155,39 +162,40 @@ GEM
highline (2.0.3)
http-cookie (1.0.8)
domain_name (~> 0.5)
httpclient (2.8.3)
httpclient (2.9.0)
mutex_m
jmespath (1.6.2)
json (2.9.1)
jwt (2.10.1)
json (2.18.0)
jwt (2.10.2)
base64
logger (1.7.0)
mini_magick (4.13.2)
mini_mime (1.1.5)
multi_json (1.15.0)
multi_json (1.18.0)
multipart-post (2.4.1)
mutex_m (0.3.0)
nanaimo (0.4.0)
naturally (2.2.1)
naturally (2.3.0)
nkf (0.2.0)
optparse (0.6.0)
optparse (0.8.1)
os (1.1.4)
ostruct (0.6.1)
plist (3.7.2)
public_suffix (6.0.1)
rake (13.2.1)
public_suffix (7.0.0)
rake (13.3.1)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (3.1.2)
rexml (3.4.0)
rexml (3.4.4)
rouge (3.28.0)
ruby2_keywords (0.0.5)
rubyzip (2.4.1)
security (0.1.5)
signet (0.19.0)
signet (0.21.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 3.0)
jwt (>= 1.5, < 4.0)
multi_json (~> 1.10)
simctl (1.6.10)
CFPropertyList
@@ -211,7 +219,7 @@ GEM
colored2 (~> 3.1)
nanaimo (~> 0.4.0)
rexml (>= 3.3.6, < 4.0)
xcpretty (0.4.0)
xcpretty (0.4.1)
rouge (~> 3.28.0)
xcpretty-travis-formatter (1.0.1)
xcpretty (~> 0.2, >= 0.0.7)
@@ -221,11 +229,8 @@ PLATFORMS
ruby
DEPENDENCIES
abbrev
fastlane
fastlane-plugin-sentry
mutex_m
ostruct
BUNDLED WITH
2.6.2
+2 -2
View File
@@ -1,3 +1,3 @@
krzysztofzablocki/Sourcery@2.3.0
cpisciotta/xcbeautify@2.28.0
yonaskolb/xcodegen@2.42.0
cpisciotta/xcbeautify@3.1.2
yonaskolb/xcodegen@2.44.1
@@ -153,10 +153,8 @@ final class UserNotificationCenterDelegate: NSObject, UNUserNotificationCenterDe
}
private func waitUntilSessionBecomesActive(sessionId: String) async {
for await sessionState in sessionStatePublisher.values {
if (try? sessionState.userSession?.sessionId().get()) == sessionId {
break
}
for await sessionState in sessionStatePublisher.values where (try? sessionState.userSession?.sessionId().get()) == sessionId {
break
}
}
@@ -5,6 +5,30 @@
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "calculator_dark-1024@1x.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"filename" : "calculator_tinted-1024@1x.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

@@ -5,6 +5,30 @@
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "notes_dark-1024@1x.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"filename" : "notes_tinted-1024@1x.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

@@ -5,6 +5,30 @@
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "weather_dark-1024@1x.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"filename" : "weather_tinted-1024@1x.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

@@ -5,15 +5,48 @@
"idiom" : "universal",
"scale" : "1x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "calculator_dark@1x.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "app-icon-disguised-calculator-29@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "calculator_dark@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "app-icon-disguised-calculator-29@3x.png",
"idiom" : "universal",
"scale" : "3x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "calculator_dark@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
Binary file not shown.

After

Width:  |  Height:  |  Size: 573 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 927 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -5,15 +5,48 @@
"idiom" : "universal",
"scale" : "1x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "notes_dark@1x.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "app-icon-disguised-notes-29@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "notes_dark@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "app-icon-disguised-notes-29@3x.png",
"idiom" : "universal",
"scale" : "3x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "notes_dark@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
Binary file not shown.

After

Width:  |  Height:  |  Size: 638 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

@@ -5,15 +5,48 @@
"idiom" : "universal",
"scale" : "1x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "weather_dark@1x.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "app-icon-disguised-weather-29@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "weather_dark@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "app-icon-disguised-weather-29@3x.png",
"idiom" : "universal",
"scale" : "3x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "weather_dark@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
Binary file not shown.

After

Width:  |  Height:  |  Size: 638 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
File diff suppressed because it is too large Load Diff
@@ -23,12 +23,13 @@ enum PreviewData {
extension LabelUIModel {
static func random(num: Int) -> [LabelUIModel] {
(0..<num).map { _ in
LabelUIModel(
labelId: .random(),
text: ["a", "b", "c"].randomElement()!,
color: [Color.blue, .red, .green].randomElement()!
)
}
(0..<num)
.map { _ in
LabelUIModel(
labelId: .random(),
text: ["a", "b", "c"].randomElement()!,
color: [Color.blue, .red, .green].randomElement()!
)
}
}
}
+2 -1
View File
@@ -89,7 +89,8 @@ final class AppContext: Sendable, ObservableObject {
hvNotifier: accountChallengeCoordinator,
deviceInfoProvider: ChallengePayloadProvider(),
issueReporter: SentryIssueReporter()
).get()
)
.get()
excludeDirectoriesFromBackup(params: params)
@@ -128,39 +128,40 @@ final class MailboxModel: ObservableObject {
extension MailboxModel {
private func setUpBindings() {
appRoute.$route.sink { [weak self] route in
guard let self else { return }
appRoute.$route
.sink { [weak self] route in
guard let self else { return }
switch route {
case .mailbox(selectedMailbox: let newSelectedMailbox):
guard newSelectedMailbox != selectedMailbox else {
return
}
switch route {
case .mailbox(selectedMailbox: let newSelectedMailbox):
guard newSelectedMailbox != selectedMailbox else {
return
}
Task {
self.selectionMode.selectionModifier.exitSelectionMode()
self.selectedMailbox = newSelectedMailbox
await self.updateMailboxAndScroller()
await self.prepareSwipeActions()
}
case .mailboxOpenMessage(seed: let openedItem):
state.isSearchPresented = false
replaceCurrentNavigationPath(with: openedItem)
case .composer(let fromShareExtension):
state.isSearchPresented = false
Task {
self.selectionMode.selectionModifier.exitSelectionMode()
self.selectedMailbox = newSelectedMailbox
await self.updateMailboxAndScroller()
await self.prepareSwipeActions()
}
case .mailboxOpenMessage(seed: let openedItem):
state.isSearchPresented = false
replaceCurrentNavigationPath(with: openedItem)
case .composer(let fromShareExtension):
state.isSearchPresented = false
if fromShareExtension {
openDraftForShareExtension()
} else {
createDraft()
if fromShareExtension {
openDraftForShareExtension()
} else {
createDraft()
}
case .mailto(let mailtoURL):
createDraft(with: mailtoURL)
case .search:
state.isSearchPresented = true
}
case .mailto(let mailtoURL):
createDraft(with: mailtoURL)
case .search:
state.isSearchPresented = true
}
}
.store(in: &cancellables)
.store(in: &cancellables)
Publishers.Merge(
mailSettingsLiveQuery.settingHasChanged(keyPath: \.swipeLeft),
@@ -312,14 +313,16 @@ extension MailboxModel {
callback: MessageScrollerLiveQueryCallbackWrapper { [weak self] update in
self?.scrollerUpdates.enqueueUpdate(update)
}
).get()
)
.get()
} else {
conversationScroller = try await scrollConversationsForLabel(
mailbox: mailbox,
callback: ConversationScrollerLiveQueryCallbackWrapper { [weak self] update in
self?.scrollerUpdates.enqueueUpdate(update)
}
).get()
)
.get()
}
paginatedDataSource.fetchInitialPage()
@@ -406,7 +409,7 @@ extension MailboxModel {
case .append(let conversations):
let items = await mailboxItems(conversations: conversations)
updateType = .append(items: items)
case let .replaceRange(from, to, conversations):
case .replaceRange(let from, let to, let conversations):
let items = await mailboxItems(conversations: conversations)
updateType = .replaceRange(from: Int(from), to: Int(to), items: items)
completion = { [weak self] in self?.updateSelectedItemsAfterDestructiveUpdate() }
@@ -451,7 +454,7 @@ extension MailboxModel {
case .append(let messages):
let items = await mailboxItems(messages: messages)
updateType = .append(items: items)
case let .replaceRange(from, to, messages):
case .replaceRange(let from, let to, let messages):
let items = await mailboxItems(messages: messages)
updateType = .replaceRange(from: Int(from), to: Int(to), items: items)
completion = { [weak self] in self?.updateSelectedItemsAfterDestructiveUpdate() }
@@ -547,7 +550,8 @@ extension MailboxModel {
showLocation: showLocation
)
}
}.value
}
.value
}
private func mailboxItems(conversations: [Conversation]) async -> [MailboxItemCellUIModel] {
@@ -558,7 +562,8 @@ extension MailboxModel {
conversations.map { conversation in
conversation.toMailboxItemCellUIModel(selectedIds: selectedIds, showLocation: showLocation)
}
}.value
}
.value
}
}
@@ -690,11 +695,12 @@ extension MailboxModel {
func onMailboxItemAction(_ context: SwipeActionContext, toastStateStore: ToastStateStore) {
guard let mailbox,
let output = swipeActionsHandler?.handle(
context,
toastStateStore: toastStateStore,
viewMode: mailbox.viewMode()
)
let output = swipeActionsHandler?
.handle(
context,
toastStateStore: toastStateStore,
viewMode: mailbox.viewMode()
)
else { return }
switch output.sheetType {
case .labelAs:
@@ -92,7 +92,8 @@ struct ConversationActionsMenu<OpenMenuButtonContent: View>: View {
actions = try await allAvailableConversationActionsForActionSheet(
mailbox: mailbox,
conversationId: conversationID
).get()
)
.get()
} catch {
AppLogger.log(error: error, category: .conversationDetail)
}
@@ -28,6 +28,6 @@ extension DraftProvider {
}
static var dummy: Self {
.init(makeDraft: { _, _ in return NewDraftResult.ok(.init(noPointer: .init())) })
.init(makeDraft: { _, _ in NewDraftResult.ok(.init(noPointer: .init())) })
}
}
@@ -43,7 +43,8 @@ struct LabelAsActionPerformer {
input.selectedLabelsIDs,
input.partiallySelectedLabelsIDs,
input.archive
).get()
)
.get()
return output
}
@@ -29,17 +29,15 @@ struct ReadActionPerformer: Sendable {
self.readActionPerformerActions = readActionPerformerActions
}
func markAsRead(itemsWithIDs ids: [ID], itemType: MailboxItemType, completion: (() -> Void)? = nil) {
func markAsRead(itemsWithIDs ids: [ID], itemType: MailboxItemType) {
Task {
await markAsRead(itemsWithIDs: ids, itemType: itemType)
completion?()
}
}
func markAsUnread(itemsWithIDs ids: [ID], itemType: MailboxItemType, completion: (() -> Void)? = nil) {
func markAsUnread(itemsWithIDs ids: [ID], itemType: MailboxItemType) {
Task {
await markAsUnread(itemsWithIDs: ids, itemType: itemType)
completion?()
}
}
@@ -29,7 +29,7 @@ final class SendResultPresenter {
private typealias MessageID = ID
private let regularDuration: Toast.Duration = .short
private let extendedDuration: TimeInterval = 3.0
private var toasts = [MessageID: Toast]()
private var toasts: [MessageID: Toast] = [:]
private let subject = PassthroughSubject<SendResultToastAction, Never>()
private let draftPresenter: DraftPresenter
@@ -17,7 +17,7 @@
import proton_app_uniffi
protocol SnoozeServiceProtocol: Sendable {
protocol SnoozeServiceProtocol {
func availableSnoozeActions(for conversation: [Id], systemCalendarWeekStart: NonDefaultWeekStart) async -> AvailableSnoozeActionsForConversationResult
func snooze(conversation ids: [Id], labelId: Id, timestamp: UnixTimestamp) async -> SnoozeConversationsResult
func unsnooze(conversation ids: [Id], labelId: Id) async -> UnsnoozeConversationsResult
@@ -36,7 +36,7 @@ extension SnoozeState {
labelId: ID,
conversationIDs: [ID]
) -> Self {
return .init(
.init(
conversationIDs: conversationIDs,
labelId: labelId,
screen: screen,
@@ -84,10 +84,12 @@ class SnoozeStore: StateStore {
private func loadSnoozeData() async {
do {
let snoozeActions = try await snoozeService.availableSnoozeActions(
for: state.conversationIDs,
systemCalendarWeekStart: DateEnvironment.calendar.nonDefaultWeekStart
).get()
let snoozeActions =
try await snoozeService.availableSnoozeActions(
for: state.conversationIDs,
systemCalendarWeekStart: DateEnvironment.calendar.nonDefaultWeekStart
)
.get()
state = state.copy(\.snoozeActions, to: snoozeActions)
} catch {
@@ -98,11 +100,13 @@ class SnoozeStore: StateStore {
private func snoozeConversations(snoozeTime: UnixTimestamp) async {
do {
_ = try await snoozeService.snooze(
conversation: state.conversationIDs,
labelId: state.labelId,
timestamp: snoozeTime
).get()
_ =
try await snoozeService.snooze(
conversation: state.conversationIDs,
labelId: state.labelId,
timestamp: snoozeTime
)
.get()
toastStateStore.present(toast: .snooze(snoozeDate: snoozeTime.date))
dismiss()
} catch {
@@ -113,10 +117,12 @@ class SnoozeStore: StateStore {
private func unsnoozeConversations() async {
do {
_ = try await snoozeService.unsnooze(
conversation: state.conversationIDs,
labelId: state.labelId
).get()
_ =
try await snoozeService.unsnooze(
conversation: state.conversationIDs,
labelId: state.labelId
)
.get()
toastStateStore.present(toast: .unsnooze)
dismiss()
} catch {
@@ -37,7 +37,7 @@ struct UndoScheduleSendProvider {
}
static func mockInstance(
stubbedResult: DraftCancelScheduleSendResult = .ok(.init(lastScheduledTime: 1747728129))
stubbedResult: DraftCancelScheduleSendResult = .ok(.init(lastScheduledTime: 1_747_728_129))
) -> UndoScheduleSendProvider {
.init(undoScheduleSend: { _ in stubbedResult })
}
@@ -23,20 +23,21 @@ enum NotificationAuthorizationRequestTrigger: CaseIterable {
case messageSent
}
@MainActor
final class NotificationAuthorizationStore {
private let userDefaults: UserDefaults
private let userNotificationCenter: UserNotificationCenter
private let userNotificationCenter: () -> UserNotificationCenter
init(
userDefaults: UserDefaults,
userNotificationCenter: UserNotificationCenter = UNUserNotificationCenter.current()
userNotificationCenter: @escaping () -> UserNotificationCenter = UNUserNotificationCenter.current
) {
self.userDefaults = userDefaults
self.userNotificationCenter = userNotificationCenter
}
func shouldRequestAuthorization(trigger: NotificationAuthorizationRequestTrigger) async -> Bool {
let authorizationStatus = await userNotificationCenter.authorizationStatus()
let authorizationStatus = await userNotificationCenter().authorizationStatus()
guard authorizationStatus == .notDetermined else {
return false
@@ -62,7 +63,7 @@ final class NotificationAuthorizationStore {
private func requestNotificationAuthorization() async {
do {
_ = try await userNotificationCenter.requestAuthorization(options: [.alert, .badge, .sound])
_ = try await userNotificationCenter().requestAuthorization(options: [.alert, .badge, .sound])
} catch {
AppLogger.log(error: error, category: .notifications)
}
@@ -19,10 +19,14 @@ import SwiftUI
enum AppIcon: CaseIterable, Hashable {
case `default`
case notes
case weather
case notes
case calculator
static var alternateIcons: [AppIcon] {
allCases.filter { icon in icon != .default }
}
init(rawValue: String?) {
switch rawValue {
case Self.appIconNotes: self = .notes
@@ -32,15 +36,6 @@ enum AppIcon: CaseIterable, Hashable {
}
}
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
@@ -52,10 +47,10 @@ enum AppIcon: CaseIterable, Hashable {
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
case .default: ImageResource.appIconPreview
case .weather: ImageResource.appIconWeatherPreview
case .notes: ImageResource.appIconNotesPreview
case .calculator: ImageResource.appIconCalculatorPreview
}
}
@@ -17,11 +17,11 @@
import UIKit
@MainActor
protocol AppIconConfigurable {
var alternateIconName: String? { get }
var supportsAlternateIcons: Bool { get }
@MainActor
func setAlternateIconName(_ alternateIconName: String?) async throws
}
@@ -0,0 +1,145 @@
// 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 InboxCore
import InboxCoreUI
import InboxDesignSystem
import ProtonUIFoundations
import SwiftUI
struct AppIconScreen: View {
@StateObject var store: AppIconStateStore
init(appIconConfigurator: AppIconConfigurable = UIApplication.shared) {
_store = .init(
wrappedValue: .init(
state: .initial(appIcon: appIconConfigurator.currentIcon),
appIconConfigurator: appIconConfigurator
)
)
}
var body: some View {
ScrollView {
VStack(spacing: DS.Spacing.extraLarge) {
FormSection {
VStack(alignment: .leading, spacing: .zero) {
Image(store.state.appIcon.preview)
.resizable()
.square(size: 60)
.clippedRoundedBorder(cornerRadius: DS.Radius.extraLarge, lineColor: DS.Color.Border.norm)
Text(L10n.Settings.AppIcon.title)
.font(.title3.bold())
.foregroundStyle(DS.Color.Text.norm)
.padding(.top, DS.Spacing.moderatelyLarge)
Text(L10n.Settings.AppIcon.description)
.font(.subheadline)
.foregroundStyle(DS.Color.Text.weak)
.tint(DS.Color.Text.accent)
.multilineTextAlignment(.leading)
.padding(.top, DS.Spacing.compact)
}
.padding(.bottom, DS.Spacing.extraLarge)
.padding(.horizontal, DS.Spacing.large)
DS.Color.Border.norm
.frame(height: 1)
.padding(.leading, DS.Spacing.large)
FormSwitchView(title: L10n.Settings.AppIcon.discreetToggle, isOn: isDiscreetAppIconOn)
.padding(.bottom, DS.Spacing.compact)
}
.frame(maxWidth: .infinity)
.background(DS.Color.BackgroundInverted.secondary)
.roundedRectangleStyle()
.animation(.none, value: store.state.isDiscreetAppIconOn)
if store.state.isDiscreetAppIconOn {
FormSection {
HStack(alignment: .center, spacing: DS.Spacing.jumbo) {
ForEach(AppIcon.alternateIcons, id: \.self) { icon in
Button(action: { store.handle(action: .iconTapped(icon: icon)) }) {
let viewModel = store.state.viewModel(for: icon)
Image(icon.preview)
.resizable()
.square(size: 60)
.overlay {
RoundedRectangle(cornerRadius: DS.Radius.extraLarge)
.stroke(DS.Color.BackgroundInverted.secondary, lineWidth: viewModel.overlayLineWidth)
}
.clippedRoundedBorder(
cornerRadius: DS.Radius.extraLarge,
lineColor: viewModel.borderLineColor,
lineWidth: viewModel.borderLineWidth
)
}
}
}
.frame(maxWidth: .infinity)
.padding(.all, DS.Spacing.extraLarge)
.background(DS.Color.BackgroundInverted.secondary)
.roundedRectangleStyle()
}
.transition(.opacity)
}
}
.animation(.default, value: store.state.isDiscreetAppIconOn)
.padding(.horizontal, DS.Spacing.large)
.padding(.bottom, DS.Spacing.extraLarge)
}
.frame(maxWidth: .infinity)
.background(DS.Color.BackgroundInverted.norm)
}
private var isDiscreetAppIconOn: Binding<Bool> {
.init(
get: { store.state.isDiscreetAppIconOn },
set: { newValue in store.handle(action: .discreetAppIconSwitched(isEnabled: newValue)) }
)
}
}
#Preview {
AppIconScreen()
}
private extension AppIconConfigurable {
var currentIcon: AppIcon {
if let alternateIconName {
AppIcon(rawValue: alternateIconName)
} else {
AppIcon.default
}
}
}
private struct AppIconItemViewModel {
let overlayLineWidth: CGFloat
let borderLineColor: Color
let borderLineWidth: CGFloat
}
private extension AppIconState {
func viewModel(for icon: AppIcon) -> AppIconItemViewModel {
let isSelected = appIcon == icon
return AppIconItemViewModel(
overlayLineWidth: isSelected ? 12 : 0,
borderLineColor: isSelected ? DS.Color.Text.accent : DS.Color.Border.norm,
borderLineWidth: isSelected ? 3 : 1
)
}
}
@@ -0,0 +1,23 @@
// 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 InboxCore
enum AppIconScreenAction {
case iconTapped(icon: AppIcon)
case discreetAppIconSwitched(isEnabled: Bool)
}
@@ -0,0 +1,29 @@
// 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 InboxCore
struct AppIconState: Copying, Equatable {
var appIcon: AppIcon
var isDiscreetAppIconOn: Bool
}
extension AppIconState {
static func initial(appIcon: AppIcon) -> Self {
.init(appIcon: appIcon, isDiscreetAppIconOn: appIcon != .default)
}
}
@@ -0,0 +1,46 @@
// 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 Combine
import InboxCoreUI
class AppIconStateStore: StateStore {
@Published var state: AppIconState
private let appIconConfigurator: AppIconConfigurable
init(state: AppIconState, appIconConfigurator: AppIconConfigurable) {
self.state = state
self.appIconConfigurator = appIconConfigurator
}
func handle(action: AppIconScreenAction) async {
switch action {
case .iconTapped(let icon):
guard icon != state.appIcon else { return }
await changeIcon(to: icon)
case .discreetAppIconSwitched(let isEnabled):
let icon = (isEnabled ? AppIcon.alternateIcons.first : nil) ?? .default
state.isDiscreetAppIconOn = isEnabled
await changeIcon(to: icon)
}
}
private func changeIcon(to appIcon: AppIcon) async {
state = state.copy(\.appIcon, to: appIcon)
try? await appIconConfigurator.setAlternateIconName(appIcon.alternateIconName)
}
}
@@ -81,7 +81,8 @@ struct AppProtectionSelectionScreen: View {
) {
store.handle(action: .autoLockTapped)
}
}.animation(.easeInOut, value: state.shouldShowAutoLockButton)
}
.animation(.easeInOut, value: state.shouldShowAutoLockButton)
}
Spacer()
}
@@ -27,5 +27,4 @@ enum AppSettingsAction {
case combinedContactsChanged(Bool)
case alternativeRoutingChanged(Bool)
case swipeToAdjacentConversationChanged(Bool)
case appIconSelected(AppIcon)
}
@@ -37,7 +37,7 @@ struct AppSettingsScreen: View {
) {
_store = .init(
wrappedValue: .init(
state: state ?? .initial(appIconName: appIconConfigurator.alternateIconName),
state: state ?? .initial(isDiscreetAppIconEnabled: appIconConfigurator.isCustomIconSet),
appSettingsRepository: appSettingsRepository,
customSettings: customSettings,
appIconConfigurator: appIconConfigurator
@@ -76,7 +76,12 @@ struct AppSettingsScreen: View {
action: { router.go(to: .appProtection) }
)
if appIconConfigurator.supportsAlternateIcons {
appIconButton
FormBigButton(
title: L10n.Settings.AppIcon.buttonTitle,
symbol: .chevronRight,
value: store.state.appIconVariant.string,
action: { router.go(to: .appIcon) }
)
}
}
FormSection(footer: L10n.Settings.App.combinedContactsInfo) {
@@ -168,30 +173,6 @@ 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 useCombinedContacts: Binding<Bool> {
.init(
get: { store.state.storedAppSettings.useCombineContacts },
@@ -216,7 +197,10 @@ struct AppSettingsScreen: View {
#Preview {
NavigationStack {
AppSettingsScreen(state: .initial(appIconName: .none), customSettings: CustomSettings(noPointer: .init()))
AppSettingsScreen(
state: .initial(isDiscreetAppIconEnabled: false),
customSettings: CustomSettings(noPointer: .init())
)
}
}
@@ -256,3 +240,9 @@ private extension AppProtection {
}
}
}
private extension AppIconConfigurable {
var isCustomIconSet: Bool {
alternateIconName != nil
}
}
@@ -23,13 +23,13 @@ struct AppSettingsState: Copying, Equatable {
var areNotificationsEnabled: Bool
var appLanguage: String
var storedAppSettings: AppSettings
var appIcon: AppIcon
var isDiscreetAppIconEnabled: Bool
var isAppearanceMenuShown: Bool
var isSwipeToAdjacentConversationEnabled: Bool
}
extension AppSettingsState {
static func initial(appIconName: String?) -> Self {
static func initial(isDiscreetAppIconEnabled: Bool) -> Self {
.init(
areNotificationsEnabled: false,
appLanguage: .empty,
@@ -40,7 +40,7 @@ extension AppSettingsState {
useCombineContacts: false,
useAlternativeRouting: true
),
appIcon: AppIcon(rawValue: appIconName),
isDiscreetAppIconEnabled: isDiscreetAppIconEnabled,
isAppearanceMenuShown: false,
isSwipeToAdjacentConversationEnabled: false
)
@@ -49,4 +49,8 @@ extension AppSettingsState {
var areNotificationsEnabledHumanReadable: LocalizedStringResource {
areNotificationsEnabled ? CommonL10n.on : CommonL10n.off
}
var appIconVariant: LocalizedStringResource {
isDiscreetAppIconEnabled ? L10n.Settings.AppIcon.discreet : L10n.Settings.AppIcon.defaultIcon
}
}
@@ -69,20 +69,9 @@ final class AppSettingsStateStore: StateStore, Sendable {
case .swipeToAdjacentConversationChanged(let value):
_ = await customSettings.setSwipeToAdjacentConversation(enabled: value)
await refreshSwipeToAdjacentSettings()
case .appIconSelected(let appIcon):
await updateAppIcon(appIcon)
}
}
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 {
@@ -119,6 +108,7 @@ final class AppSettingsStateStore: StateStore, Sendable {
.copy(\.areNotificationsEnabled, to: areNotificationsEnabled)
.copy(\.appLanguage, to: appLangaugeProvider.appLangauge)
.copy(\.isSwipeToAdjacentConversationEnabled, to: isSwipeToAdjacentEnabled)
.copy(\.isDiscreetAppIconEnabled, to: appIconConfigurator.alternateIconName != nil)
} catch {
AppLogger.log(error: error, category: .appSettings)
}
@@ -52,7 +52,8 @@ struct CustomizeToolbarsScreen: View {
}
.padding(.horizontal, DS.Spacing.large)
.padding(.bottom, DS.Spacing.extraLarge)
}.onAppear {
}
.onAppear {
store.handle(action: .onAppear)
}
.onChange(
@@ -67,9 +67,10 @@ class EditToolbarStore: StateStore {
to: .init(selected: selectedList, unselected: unselectedList))
case .onLoad:
do {
let actions = try await customizeToolbarRepository.fetchActions()[
keyPath: state.toolbarType.actionsKeyPath
]
let actions =
try await customizeToolbarRepository.fetchActions()[
keyPath: state.toolbarType.actionsKeyPath
]
state = state.copy(\.toolbarActions, to: actions)
} catch {
AppLogger.log(error: error, category: .customizeToolbar)
@@ -35,7 +35,8 @@ struct PINRouterView: View {
view(route: route)
.navigationBarBackButtonHidden()
}
}.environmentObject(router)
}
.environmentObject(router)
}
private var navigationPath: Binding<[PINRoute]> {
@@ -95,25 +95,25 @@ final class MessageBodyStateStore: StateStore {
case .onLoad:
await loadMessageBody(with: .init())
case .refreshBanners:
if case let .loaded(body, _) = state.body {
if case .loaded(let body, _) = state.body {
await loadMessageBody(with: body.html.options)
}
case .displayEmbeddedImages:
if case let .loaded(body, _) = state.body {
if case .loaded(let body, _) = state.body {
let updatedOptions = body.html.options
.copy(\.hideEmbeddedImages, to: false)
await loadMessageBody(with: updatedOptions)
}
case .downloadRemoteContent:
if case let .loaded(body, _) = state.body {
if case .loaded(let body, _) = state.body {
let updatedOptions = body.html.options
.copy(\.hideRemoteImages, to: false)
await loadMessageBody(with: updatedOptions)
}
case .reloadFailedProxyImages:
if case let .loaded(body, _) = state.body {
if case .loaded(let body, _) = state.body {
var newBanners = state.eventBanners
newBanners.remove(.proxyImageLoadFail)
state =
@@ -131,11 +131,11 @@ final class MessageBodyStateStore: StateStore {
case .markAsLegitimateConfirmed(let action):
state = state.copy(\.alert, to: nil)
if case let .loaded(body, _) = state.body, case .markAsLegitimate = action {
if case .loaded(let body, _) = state.body, case .markAsLegitimate = action {
await markAsLegitimate(with: body.html.options)
}
case .unblockSender(let emailAddress):
if case let .loaded(body, _) = state.body {
if case .loaded(let body, _) = state.body {
await unblockSender(emailAddress: emailAddress, with: body.html.options)
}
case .unsubscribeNewsletter:
@@ -146,7 +146,7 @@ final class MessageBodyStateStore: StateStore {
case .unsubscribeNewsletterConfirmed(let action):
state = state.copy(\.alert, to: nil)
if case let .loaded(body, _) = state.body, case .unsubscribe = action {
if case .loaded(let body, _) = state.body, case .unsubscribe = action {
await unsubscribeNewsletter(with: body.newsletterService, options: body.html.options)
}
}
@@ -173,5 +173,6 @@ enum ExpandedMessageCellEvent {
onEvent: { _ in },
htmlDisplayed: {}
)
}.environmentObject(ToastStateStore(initialState: .initial))
}
.environmentObject(ToastStateStore(initialState: .initial))
}
@@ -139,7 +139,7 @@ struct MessageBodyAttachmentsView: View {
private extension Array where Element == AttachmentDisplayModel {
var totalSize: Int64 {
reduce(0) { result, next in
return result + Int64(next.size)
result + Int64(next.size)
}
}
}
@@ -26,7 +26,7 @@ struct MessageActionButtonsView: View {
var onEvent: (ReplyAction) -> Void
var body: some View {
HStack() {
HStack {
MessageActionButtonView(symbol: .reply, text: L10n.Action.reply, isDisabled: isDisabled) {
onEvent(.reply)
}
@@ -542,7 +542,7 @@ enum MessageDetailsPreviewProvider {
recipientsTo: recipientsTo,
recipientsCc: recipientsCc,
recipientsBcc: recipientsBcc,
date: Date(timeIntervalSince1970: 1724347300),
date: Date(timeIntervalSince1970: 1_724_347_300),
location: location?.model,
labels: labels,
isStarred: false,
@@ -79,7 +79,6 @@ final class ConversationDetailModel: Sendable, ObservableObject {
private var singleMessageLiveQuery: WatchedMessage?
private var expandedMessages: Set<ID>
private let draftPresenter: DraftPresenter
private let dependencies: Dependencies
private let backOnlineActionExecutor: BackOnlineActionExecutor
private let snoozeService: SnoozeServiceProtocol
@@ -105,20 +104,18 @@ final class ConversationDetailModel: Sendable, ObservableObject {
private lazy var starActionPerformer = StarActionPerformer(mailUserSession: userSession)
private var userSession: MailUserSession {
dependencies.appContext.userSession
dependencies.userSession
}
init(
seed: ConversationDetailSeed,
draftPresenter: DraftPresenter,
dependencies: Dependencies = .init(),
dependencies: Dependencies,
backOnlineActionExecutor: BackOnlineActionExecutor,
snoozeService: SnoozeServiceProtocol
) {
self.seed = seed
self.isStarred = seed.isStarred
self.expandedMessages = .init()
self.draftPresenter = draftPresenter
self.dependencies = dependencies
self.backOnlineActionExecutor = backOnlineActionExecutor
self.snoozeService = snoozeService
@@ -355,8 +352,10 @@ final class ConversationDetailModel: Sendable, ObservableObject {
case .forward:
actionSheets = .allSheetsDismissed
onReplyAction(messageId: messageID, action: .forward, toastStateStore: toastStateStore)
case .viewHeaders, .viewHtml:
toastStateStore.present(toast: .comingSoon)
case .viewHeaders:
await presentQuickLook(id: messageID, type: .headers, toastStateStore: toastStateStore)
case .viewHtml:
await presentQuickLook(id: messageID, type: .body, toastStateStore: toastStateStore)
case .print:
do {
try await messagePrinter.printMessage(messageID: messageID)
@@ -420,12 +419,13 @@ final class ConversationDetailModel: Sendable, ObservableObject {
let alert: AlertModel = .deleteConfirmation(
itemsCount: 1,
action: { [weak self] action in
await self?.handle(
id: conversationID,
mailboxItem: .conversation,
action: action,
toastStateStore: toastStateStore, goBack: goBack
)
await self?
.handle(
id: conversationID,
mailboxItem: .conversation,
action: action,
toastStateStore: toastStateStore, goBack: goBack
)
}
)
actionAlert = alert
@@ -498,6 +498,15 @@ final class ConversationDetailModel: Sendable, ObservableObject {
}
}
}
private func presentQuickLook(id: ID, type: MessageQuickLookType, toastStateStore: ToastStateStore) async {
do {
try await dependencies.messageQuickLook.present(messageID: id, mailbox: mailbox!, type: type)
} catch {
AppLogger.log(error: error)
toastStateStore.present(toast: .error(message: error.localizedDescription))
}
}
}
extension ConversationDetailModel {
@@ -508,7 +517,7 @@ extension ConversationDetailModel {
}
private func openDraft(with id: ID) {
draftPresenter.openDraft(withId: id)
dependencies.draftPresenter.openDraft(withId: id)
}
private func move(
@@ -553,10 +562,6 @@ extension ConversationDetailModel {
}
private func initialiseMailbox(basedOn selectedMailbox: SelectedMailbox) async throws -> Mailbox {
guard let userSession = dependencies.appContext.sessionState.userSession else {
throw ConversationModelError.noActiveSessionFound
}
switch selectedMailbox {
case .inbox:
return try newInboxMailbox(ctx: userSession).get()
@@ -589,10 +594,6 @@ extension ConversationDetailModel {
}
private func fetchMessage(with remoteId: RemoteId) async throws -> Message {
guard let userSession = dependencies.appContext.sessionState.userSession else {
throw ConversationModelError.noActiveSessionFound
}
let localId = try await resolveMessageId(session: userSession, remoteId: remoteId).get()
if let message = try await message(session: userSession, id: localId).get() {
@@ -758,7 +759,8 @@ extension ConversationDetailModel {
mailbox: mailbox,
id: conversationID,
showAll: showAllMessages
).get()
)
.get()
let hiddenMessagesBanner = conversationAndMessages?.conversation.hiddenMessagesBanner
let isStarred = conversationAndMessages?.conversation.isStarred ?? false
let messages = conversationAndMessages?.messages ?? []
@@ -799,7 +801,7 @@ extension ConversationDetailModel {
private func onReplyAction(messageId: ID, action: ReplyAction, toastStateStore: ToastStateStore) {
Task {
do {
try await draftPresenter.handleReplyAction(for: messageId, action: action)
try await dependencies.draftPresenter.handleReplyAction(for: messageId, action: action)
} catch {
toastStateStore.present(toast: .error(message: error.localizedDescription))
}
@@ -815,7 +817,7 @@ extension ConversationDetailModel {
editScheduledMessageConfirmationAlert = nil
if action == .edit {
do {
try await self.draftPresenter.cancelScheduledMessageAndOpenDraft(for: messageId)
try await dependencies.draftPresenter.cancelScheduledMessageAndOpenDraft(for: messageId)
goBack()
} catch {
switch error {
@@ -846,7 +848,8 @@ extension ConversationDetailModel {
let actions = try await allAvailableConversationActionsForConversation(
mailbox: mailbox,
conversationId: conversationItem.id
).get()
)
.get()
self.conversationToolbarActions = .conversation(actions: actions, conversationID: conversationItem.id)
} catch {
AppLogger.log(error: error, category: .conversationDetail)
@@ -861,7 +864,8 @@ extension ConversationDetailModel {
mailbox: mailbox,
theme: theme,
messageId: conversationItem.id
).get()
)
.get()
self.conversationToolbarActions = .message(actions: actions, messageID: conversationItem.id)
} catch {
AppLogger.log(error: error, category: .conversationDetail)
@@ -914,13 +918,9 @@ extension ConversationDetailModel {
extension ConversationDetailModel {
struct Dependencies {
let appContext: AppContext
init(
appContext: AppContext = .shared,
) {
self.appContext = appContext
}
let draftPresenter: DraftPresenter
let messageQuickLook: MessageQuickLook
let userSession: MailUserSession
}
}
@@ -953,7 +953,6 @@ enum MessageCellUIModelType: Equatable {
}
enum ConversationModelError: Error {
case noActiveSessionFound
case noMessageFound(messageID: ID)
}
@@ -34,13 +34,18 @@ struct ConversationDetailScreen: View {
seed: ConversationDetailSeed,
draftPresenter: DraftPresenter,
mailUserSession: MailUserSession,
messageQuickLook: MessageQuickLook,
onLoad: @escaping (ConversationDetailModel) -> Void,
onDidAppear: @escaping (ConversationDetailModel) -> Void
) {
self._model = StateObject(
wrappedValue: .init(
seed: seed,
draftPresenter: draftPresenter,
dependencies: .init(
draftPresenter: draftPresenter,
messageQuickLook: messageQuickLook,
userSession: mailUserSession
),
backOnlineActionExecutor: .init(mailUserSession: { mailUserSession }),
snoozeService: SnoozeService(mailUserSession: { mailUserSession })
))
@@ -211,6 +216,7 @@ struct ConversationDetailScreen: View {
),
draftPresenter: .dummy(),
mailUserSession: .dummy,
messageQuickLook: .init(),
onLoad: { _ in },
onDidAppear: { _ in }
)
@@ -227,6 +233,7 @@ struct ConversationDetailScreen: View {
)),
draftPresenter: .dummy(),
mailUserSession: .dummy,
messageQuickLook: .init(),
onLoad: { _ in },
onDidAppear: { _ in }
)
@@ -0,0 +1,24 @@
// 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 Foundation
extension FileManager {
var quickLookTemporaryDirectory: URL {
temporaryDirectory.appending(component: "MessageQuickLook", directoryHint: .isDirectory)
}
}
@@ -0,0 +1,90 @@
// 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 Foundation
import InboxCore
import proton_app_uniffi
@MainActor
@Observable
final class MessageQuickLook {
typealias RawMessageContent = (Mailbox, ID) async throws -> RawMessageContentProvider
private let fileManager: FileManager
private let rawMessageContent: RawMessageContent
var shortLivedURL: URL? {
didSet {
if let oldValue {
cleanUp(url: oldValue)
}
}
}
init(fileManager: FileManager, rawMessageContent: @escaping RawMessageContent) {
self.fileManager = fileManager
self.rawMessageContent = rawMessageContent
}
convenience init() {
self.init(fileManager: .default) { try await getMessageBody(mbox: $0, id: $1).get() }
}
func present(messageID: ID, mailbox: Mailbox, type: MessageQuickLookType) async throws {
let content = try await rawMessageContent(mailbox, messageID)
let relevantContent = relevantPart(of: content, for: type)
let uniqueFileDirectory = fileManager.quickLookTemporaryDirectory.appending(component: UUID().uuidString)
let filename = filename(for: type)
let url = uniqueFileDirectory.appendingPathComponent(filename, conformingTo: .plainText)
try? fileManager.createDirectory(at: uniqueFileDirectory, withIntermediateDirectories: true)
try Data(relevantContent.utf8).write(to: url, options: .completeFileProtection)
shortLivedURL = url
}
func dismiss() {
shortLivedURL = nil
}
private func relevantPart(of messageContent: RawMessageContentProvider, for type: MessageQuickLookType) -> String {
switch type {
case .body:
messageContent.rawBody()
case .headers:
messageContent.rawHeaders()
}
}
private func filename(for type: MessageQuickLookType) -> String {
switch type {
case .body:
"HTML"
case .headers:
"Message headers"
}
}
private func cleanUp(url: URL) {
do {
try fileManager.removeItem(at: url)
} catch {
AppLogger.log(error: error)
}
}
}
@@ -0,0 +1,21 @@
// 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/.
enum MessageQuickLookType {
case body
case headers
}
@@ -0,0 +1,25 @@
// 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 proton_app_uniffi
protocol RawMessageContentProvider: Sendable {
func rawBody() -> String
func rawHeaders() -> String
}
extension DecryptedMessage: RawMessageContentProvider {}
@@ -49,6 +49,7 @@ struct HomeScreen: View {
@StateObject private var appRoute: AppRouteState
@StateObject private var composerCoordinator: ComposerCoordinator
@StateObject private var upsellEligibilityPublisher: UpsellEligibilityPublisher
@State private var messageQuickLook = MessageQuickLook()
@State private var modalState: ModalState?
@State private var isNotificationPromptPresented = false
@StateObject private var eventLoopErrorCoordinator: EventLoopErrorCoordinator
@@ -115,8 +116,9 @@ struct HomeScreen: View {
)
)
.environmentObject(composerCoordinator)
.environment(messageQuickLook)
makeSidebarScreen() { selectedItem in
makeSidebarScreen { selectedItem in
switch selectedItem {
case .upsell(let upsellType):
presentUpsellScreen(ofType: upsellType)
@@ -173,6 +175,7 @@ struct HomeScreen: View {
userDidRespond: userDidRespondToAuthorizationRequest
)
}
.quickLookPreview($messageQuickLook.shortLivedURL)
.onOpenURL(perform: handleDeepLink)
.onLoad {
Task {
@@ -248,6 +251,7 @@ struct HomeScreen: View {
if let route = DeepLinkRouteCoder.decode(deepLink: deepLink) {
modalState = nil
appUIStateStore.toggleSidebar(isOpen: false)
messageQuickLook.dismiss()
Task {
await ensurePresentedViewsAreDismissed()
@@ -44,7 +44,8 @@ private extension MailboxItemCellUIModel {
AttachmentCapsuleUIModel(id: .init(value: 1), icon: DS.Icon.icFileTypeIconPdf, name: "#34JE3KLP.pdf"),
AttachmentCapsuleUIModel(id: .init(value: 2), icon: DS.Icon.icFileTypeIconWord, name: "meeting_minutes.doc"),
AttachmentCapsuleUIModel(id: .init(value: 1), icon: DS.Icon.icFileTypeIconExcel, name: "ARR_Q2.xls"),
].randomElement()!
]
.randomElement()!
]
} else {
[]
@@ -67,7 +68,7 @@ private extension MailboxItemCellUIModel {
)
static func randomAvatar() -> AvatarUIModel {
return [j, l, s].randomElement()!
[j, l, s].randomElement()!
}
}
@@ -238,7 +238,7 @@ private struct SwipeActionGesture<SwipeGesture: Gesture>: ViewModifier {
private struct AnimatableXTransformModifier: ViewModifier, Animatable {
var x: CGFloat
var animatableData: CGFloat {
nonisolated var animatableData: CGFloat {
get { x }
set { x = newValue }
}
@@ -17,7 +17,7 @@
import UIKit
/// FIXME: this might turn out to be a subset of DeviceInfo provided by Rust - either reuse that or remove this comment
@MainActor
protocol BasicDeviceInfo {
var model: String { get }
var systemName: String { get }
@@ -35,6 +35,7 @@ struct IssueReportBuilder {
self.deviceInfo = deviceInfo
}
@MainActor
func build(with formInfo: FormInfo) -> IssueReport {
.init(
operatingSystem: "\(deviceInfo.systemName) - \(deviceInfo.model)",
@@ -244,7 +244,7 @@ final class SearchModel: ObservableObject {
case .append(let messages):
let items = await mailboxItems(messages: messages)
updateType = .append(items: items)
case let .replaceRange(from, to, messages):
case .replaceRange(let from, let to, let messages):
let items = await mailboxItems(messages: messages)
updateType = .replaceRange(from: Int(from), to: Int(to), items: items)
completion = { [weak self] in self?.updateSelectedItemsAfterDestructiveUpdate() }
@@ -274,7 +274,8 @@ final class SearchModel: ObservableObject {
showLocation: true
)
}
}.value
}
.value
}
func prepareSwipeActions() async {
@@ -25,6 +25,7 @@ import proton_app_uniffi
enum SettingsRoute: Routable {
case webView(ProtonAuthenticatedWebPage)
case appIcon
case appSettings
case appProtection
case autoLock
@@ -60,6 +61,8 @@ struct SettingsViewFactory {
.navigationBarTitleDisplayMode(.inline)
.navigationTitle(webPage.title.string)
.navigationBarBackButtonHidden(true)
case .appIcon:
AppIconScreen()
case .appSettings:
AppSettingsScreen(customSettings: customSettings(ctx: mailUserSession))
case .appProtection:
@@ -41,7 +41,7 @@ struct SignaturesScreen: View {
}
var body: some View {
return StoreView(
StoreView(
store: SignaturesStateStore(
state: initialState,
customSettings: customSettings,
@@ -116,7 +116,8 @@ struct SidebarScreen: View {
.padding(.vertical, DS.Spacing.small)
.onTapGesture(count: 5) { screenModel.handle(action: .logoTappedFiveTimes) }
separator
}.background(
}
.background(
GeometryReader { geometry in
BlurredBackground(fallbackBackgroundColor: DS.Color.Sidebar.background)
.edgesIgnoringSafeArea(.all)
@@ -225,11 +226,13 @@ struct SidebarScreen: View {
.padding(.vertical, DS.Spacing.medium)
separator
appVersionNote
}.onChange(of: appUIStateStore.sidebarState.isOpen) { _, isSidebarOpen in
}
.onChange(of: appUIStateStore.sidebarState.isOpen) { _, isSidebarOpen in
if isSidebarOpen, let first = screenModel.state.items.first {
proxy.scrollTo(first.id, anchor: .zero)
}
}.accessibilityElement(children: .contain)
}
.accessibilityElement(children: .contain)
}
.scrollDisabled(gestureState.lockedAxis == .horizontal || lastCommittedAxis == .horizontal)
.frame(maxWidth: .infinity)
@@ -36,7 +36,7 @@ struct AsyncSenderImageView<Content>: View where Content: View {
var body: some View {
content(loader.senderImage)
.onAppear() {
.onAppear {
Task {
await loader.loadImage(for: senderImageParams, colorScheme: colorScheme)
}
@@ -25,7 +25,7 @@ final class AttachmentViewCoordinator: QLPreviewControllerDataSource {
}
func numberOfPreviewItems(in controller: QLPreviewController) -> Int {
return 1
1
}
func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem {
@@ -35,9 +35,9 @@ final class AttachmentViewLoader: ObservableObject {
case .ok(let result):
let url = URL(fileURLWithPath: result.dataPath)
await updateState(.attachmentReady(url))
updateState(.attachmentReady(url))
case .error(let error):
await updateState(.error(error))
updateState(.error(error))
}
}
@@ -160,7 +160,7 @@ private struct AttachmentCapsuleStyle: ButtonStyle {
}
}
fileprivate enum Layout {
private enum Layout {
static let spacingBetweenCapsules = DS.Spacing.tiny
static let extraAttachmentsViewWidth = 42.0
static let capsuleHPadding = DS.Spacing.standard
@@ -39,7 +39,8 @@ struct AvatarCheckboxView: View {
.padding(10)
.accessibilityIdentifier(AvatarCheckboxViewIdentifiers.avatarChecked)
}
}.accessibilityElement(children: .contain)
}
.accessibilityElement(children: .contain)
} else {
AvatarView(avatar: avatar)
}
@@ -54,7 +55,7 @@ struct AvatarCheckboxView: View {
}
#Preview {
return VStack {
VStack {
AvatarCheckboxView(
isSelected: true,
avatar: .init(
@@ -0,0 +1,36 @@
// 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
extension View {
func clippedRoundedBorder(cornerRadius: CGFloat, lineColor: Color, lineWidth: CGFloat = 1) -> some View {
modifier(ClippedRoundedBorder(cornerRadius: cornerRadius, lineColor: lineColor, lineWidth: lineWidth))
}
}
private struct ClippedRoundedBorder: ViewModifier {
let cornerRadius: CGFloat
let lineColor: Color
let lineWidth: CGFloat
func body(content: Content) -> some View {
content
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
.overlay(RoundedRectangle(cornerRadius: cornerRadius).stroke(lineColor, lineWidth: lineWidth))
}
}
@@ -57,7 +57,7 @@ private struct ComposeButtonStyle: ButtonStyle {
var animation: Animation
func makeBody(configuration: Self.Configuration) -> some View {
return configuration
configuration
.label
.padding(.all, DS.Spacing.moderatelyLarge)
.background(configuration.isPressed ? DS.Color.InteractionFab.pressed : DS.Color.InteractionFab.norm)
@@ -23,6 +23,7 @@ import proton_app_uniffi
struct ConversationsPageViewController: View {
@Environment(\.presentationMode) var presentationMode
@Environment(MessageQuickLook.self) private var messageQuickLook
let startingItem: ConversationDetailSeed
let makeMailboxCursor: (ID) async -> MailboxCursorProtocol?
@@ -98,6 +99,7 @@ struct ConversationsPageViewController: View {
seed: seed,
draftPresenter: draftPresenter,
mailUserSession: userSession,
messageQuickLook: messageQuickLook,
onLoad: { if activeModel == nil { activeModel = $0 } },
onDidAppear: { newActiveModel in
activeModel = newActiveModel
@@ -279,12 +279,12 @@ private extension AssignedSwipeAction {
private extension AssignedSwipeAction {
func isDestructive(locationSystemLabel: SystemLabel?, itemSystemLabel: SystemLabel?) -> Bool {
guard case let .moveTo(location) = self else {
guard case .moveTo(let location) = self else {
return false
}
switch location {
case let .moveToSystemLabel(targetSystemLabel, _):
case .moveToSystemLabel(let targetSystemLabel, _):
switch locationSystemLabel {
case .allMail, .allSent, .allDrafts:
return false
@@ -104,9 +104,9 @@ struct MessageBodyReaderView: UIViewRepresentable {
height: auto !important;
}
table {
* {
/* This does not make sense on mobile */
float: none;
float: none !important;
}
body {
@@ -51,7 +51,8 @@ struct OneLineLabelsListView: View {
}
}
}
}.frame(height: height)
}
.frame(height: height)
}
// MARK: - Private
@@ -99,5 +100,6 @@ struct OneLineLabelsListView: View {
OneLineLabelsListView(labels: labels)
}
Spacer()
}.padding()
}
.padding()
}
@@ -25,7 +25,8 @@ enum OneLineLabelsListViewPreviewDataProvider {
["😈"],
["Long long long long long long long long long long long long long long"],
["Aaaaaaaa", "Long long label long long long long long", "aaaaaaaaaaaaa"],
].map { $0.map(LabelUIModel.testData) }
]
.map { $0.map(LabelUIModel.testData) }
}
}
@@ -20,7 +20,7 @@ import InboxCore
import SwiftUI
@MainActor
final class PaginatedListDataSource<Item: Equatable & Sendable>: ObservableObject {
final class PaginatedListDataSource<Item: Equatable>: ObservableObject {
typealias FetchMore = (_ isFetchingFirstPage: Bool) -> Void
@Published private(set) var state: State
@@ -57,13 +57,13 @@ final class PaginatedListDataSource<Item: Equatable & Sendable>: ObservableObjec
switch update.value {
case .append(let items):
newState.items.append(contentsOf: items)
case let .replaceRange(from, to, items):
case .replaceRange(let from, let to, let items):
guard isSafeIndex(from), isSafeIndex(to) else { break }
newState.items.replaceSubrange(from..<to, with: items)
case let .replaceFrom(index, items):
case .replaceFrom(let index, let items):
guard isSafeIndex(index) else { break }
newState.items.replaceSubrange(index..<newState.items.endIndex, with: items)
case let .replaceBefore(index, items):
case .replaceBefore(let index, let items):
guard isSafeIndex(index) else { break }
newState.items.replaceSubrange(newState.items.startIndex..<index, with: items)
case .none, .error:
@@ -0,0 +1,330 @@
// 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 InboxCore
import InboxCoreUI
import InboxDesignSystem
import SwiftUI
struct TrackersInfoView: View {
@Environment(\.openURL) var openURL
@Environment(\.dismiss) var dismiss
let state: TrackersInfoViewStore.State
var body: some View {
StoreView(
store: TrackersInfoViewStore(
state: state,
openUrl: openURL,
dismiss: dismiss
)
) { state, store in
ClosableScreen {
ScrollView {
VStack(alignment: .leading, spacing: DS.Spacing.large) {
header()
if state.trackers.totalTrackersCount > 0 {
trackersSection(state: state, store: store)
}
if state.trackers.totalLinksCount > 0 {
linksSection(state: state, store: store)
}
Button(action: { store.handle(action: .onGotItTap) }) {
Text(CommonL10n.gotIt)
}
.buttonStyle(BigButtonStyle())
.padding(.top, DS.Spacing.large)
Spacer()
}
.frame(maxWidth: .infinity)
.padding(.horizontal, DS.Spacing.extraLarge)
}
.background(DS.Color.BackgroundInverted.norm)
}
}
}
}
private extension TrackersInfoView {
func header() -> some View {
VStack(alignment: .leading) {
Image(DS.Icon.icShield2CheckFilled)
.resizable()
.square(size: 32)
.tint(DS.Color.Icon.norm)
.padding(DS.Spacing.extraLarge)
.background {
RoundedRectangle(cornerRadius: DS.Radius.extraLarge)
.fill(DS.Color.Background.deep)
}
Text(L10n.MessageDetails.trackerProtection)
.font(.title2)
.fontWeight(.semibold)
.foregroundStyle(DS.Color.Text.norm)
Text(L10n.TrackingInfo.description)
.font(.subheadline)
.foregroundStyle(DS.Color.Text.weak)
.tint(DS.Color.Text.accent)
.padding(.top, -DS.Spacing.compact)
}
}
func sectionSummary(title: String, isExpanded: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
HStack {
Text(title)
.font(.body)
.foregroundStyle(DS.Color.Text.norm)
Spacer()
Image(DS.Icon.icChevronTinyDown)
.resizable()
.square(size: 32)
.tint(DS.Color.Icon.norm)
.rotationEffect(.degrees(isExpanded ? -180 : 0))
}
.padding(DS.Spacing.large)
}
.background {
UnevenRoundedRectangle.top(isSectionExpanded: isExpanded)
.fill(DS.Color.BackgroundInverted.secondary)
}
.zIndex(1)
}
// MARK: trackers
func trackersSection(state: TrackersInfoViewStore.State, store: TrackersInfoViewStore) -> some View {
VStack(spacing: 0) {
sectionSummary(title: state.trackers.titleForTrackersSection, isExpanded: state.isTrackersSectionExpanded) {
store.handle(action: .onSectionTap(section: .blockedTrackers))
}
if state.isTrackersSectionExpanded {
VStack(alignment: .leading, spacing: 0) {
ForEach(Array(state.trackers.blockedTrackers.enumerated()), id: \.element.name) { index, domain in
trackerCell(domain: domain) { url in
store.handle(action: .onBlockedTrackerTap(domain: domain.name, url: url))
}
}
}
.background {
UnevenRoundedRectangle.bottom
.fill(DS.Color.BackgroundInverted.secondary)
}
.transition(.move(edge: .top).combined(with: .opacity))
}
}
.alert(
state.presentedBlockedTracker.domain,
isPresented: Binding(
get: { state.isBlockedTrackerPresented },
set: { _ in store.handle(action: .onBlockedTrackerAlertDismiss) }
)
) {
Button(CommonL10n.ok.string, role: .cancel) {}
} message: {
Text(state.presentedBlockedTracker.url)
.multilineTextAlignment(.leading)
.foregroundStyle(DS.Color.Text.norm)
}
.clipped()
}
func trackerCell(domain: TrackerDomain, onUrlTap: @escaping (String) -> Void) -> some View {
VStack(alignment: .leading, spacing: DS.Spacing.standard) {
HStack {
Text(domain.name)
.font(.body)
.foregroundStyle(DS.Color.Text.norm)
Spacer()
Text(String(domain.urls.count))
.frame(width: 32)
.font(.body)
.foregroundStyle(DS.Color.Text.norm)
}
ForEach(domain.urls, id: \.self) { url in
Button(action: { onUrlTap(url) }) {
Text(url)
.multilineTextAlignment(.leading)
.lineLimit(2)
.font(.footnote)
.foregroundStyle(DS.Color.Text.weak)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, DS.Spacing.large)
.padding(.vertical, DS.Spacing.medium)
.overlay(alignment: .top) {
Divider()
.background(DS.Color.Border.norm)
}
}
// MARK: links
func linksSection(state: TrackersInfoViewStore.State, store: TrackersInfoViewStore) -> some View {
VStack(spacing: 0) {
sectionSummary(title: state.trackers.titleForLinksSection, isExpanded: state.isLinksSectionExpanded) {
store.handle(action: .onSectionTap(section: .links))
}
if state.isLinksSectionExpanded {
VStack(alignment: .leading, spacing: 0) {
ForEach(Array(state.trackers.cleanedLinks.enumerated()), id: \.element.original) { index, link in
linkCell(link: link) { url in
store.handle(action: .onLinkTap(url: url))
}
}
}
.background {
UnevenRoundedRectangle.bottom
.fill(DS.Color.BackgroundInverted.secondary)
}
.transition(.move(edge: .top).combined(with: .opacity))
}
}
.clipped()
}
func linkCell(link: CleanedLink, onUrlTap: @escaping ((String) -> Void)) -> some View {
VStack(alignment: .leading, spacing: DS.Spacing.standard) {
linkCellRow(title: L10n.TrackingInfo.original, showArrow: false, url: link.original) {
onUrlTap(link.original)
}
Divider()
.background(DS.Color.Border.norm)
.padding(.leading, DS.Spacing.large)
linkCellRow(title: L10n.TrackingInfo.cleaned, showArrow: true, url: link.cleaned) {
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, DS.Spacing.medium)
.overlay(alignment: .top) {
Divider()
.background(DS.Color.Border.norm)
}
}
func linkCellRow(title: LocalizedStringResource, showArrow: Bool, url: String, onUrlTap: @escaping (() -> Void)) -> some View {
HStack {
VStack(alignment: .leading) {
HStack(spacing: 0) {
if showArrow {
Image(systemName: "arrow.turn.down.right")
.resizable()
.square(size: 13)
.foregroundStyle(DS.Color.Text.weak)
.padding(.trailing, DS.Spacing.small)
}
Text(title)
.font(.footnote)
.foregroundStyle(DS.Color.Text.weak)
}
Text(url)
.font(.body)
.foregroundStyle(DS.Color.Text.norm)
.lineLimit(1)
}
Spacer()
Button(action: onUrlTap) {
Image(DS.Icon.icArrowOutSquare)
.resizable()
.square(size: 20)
.foregroundStyle(DS.Color.Icon.hint)
.square(size: 32)
.contentShape(Rectangle())
}
}
.padding(.horizontal, DS.Spacing.large)
}
}
private extension UnevenRoundedRectangle {
static func top(isSectionExpanded: Bool) -> Self {
UnevenRoundedRectangle(
topLeadingRadius: DS.Radius.extraLarge,
bottomLeadingRadius: isSectionExpanded ? 0 : DS.Radius.extraLarge,
bottomTrailingRadius: isSectionExpanded ? 0 : DS.Radius.extraLarge,
topTrailingRadius: DS.Radius.extraLarge
)
}
static var bottom: Self {
UnevenRoundedRectangle(
topLeadingRadius: 0,
bottomLeadingRadius: DS.Radius.extraLarge,
bottomTrailingRadius: DS.Radius.extraLarge,
topTrailingRadius: 0
)
}
}
private extension TrackersUIModel {
var titleForTrackersSection: String {
L10n.MessageDetails.trackersBlocked(count: totalTrackersCount).string
}
var titleForLinksSection: String {
L10n.MessageDetails.linksCleaned(count: totalLinksCount).string
}
}
#Preview {
TrackersInfoView(
state: .init(
trackers: .init(
blockedTrackers: [
.init(
name: "amazon.com",
urls: [
"https://rd.goodreads.com/gp/r.html?C=B0UBQXVGFW3D&K=2IOEE0DV0PKRM&MFWFEERFKOINAPEFWEFBSF",
"https://rd.goodreads.com/gp/r.html?C=IOBEVIBIQQWIB",
]),
.init(name: "facebook.com", urls: ["facebook.com/tracker"]),
.init(
name: "google.com",
urls: [
"https://www.google.com/search?q=junk&client=safari&hs=iQf9&sca_esv=0ccf53d8b4904cec&source=hp&ei=qRxEaZCQPLTFi-gPn5qF4A8",
"https://www.google.com/search?q=junk",
]),
],
cleanedLinks: [
.init(
original: "https://www.google.com?query=ads+are+for+everyone+to+enjoy",
cleaned: "https://www.google.com"
)
])
)
)
}
@@ -0,0 +1,94 @@
// 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 Foundation
import InboxCoreUI
import SwiftUI
import SwiftUICore
@MainActor
final class TrackersInfoViewStore: StateStore {
enum Action {
case onSectionTap(section: Section)
case onBlockedTrackerTap(domain: String, url: String)
case onBlockedTrackerAlertDismiss
case onLinkTap(url: String)
case onGotItTap
}
struct State {
var trackers: TrackersUIModel
var isTrackersSectionExpanded: Bool
var isLinksSectionExpanded: Bool
var isBlockedTrackerPresented: Bool = false
var presentedBlockedTracker: (domain: String, url: String) = ("", "") {
didSet {
isBlockedTrackerPresented = !presentedBlockedTracker.domain.isEmpty || !presentedBlockedTracker.url.isEmpty
}
}
init(trackers: TrackersUIModel, isTrackersSectionExpanded: Bool = false, isLinksSectionExpanded: Bool = false) {
self.trackers = trackers
self.isTrackersSectionExpanded = isTrackersSectionExpanded
self.isLinksSectionExpanded = isLinksSectionExpanded
}
}
enum Section {
case blockedTrackers
case links
}
@Published var state: State
private let openUrl: OpenURLAction
private let dismiss: DismissAction
init(state: State, openUrl: OpenURLAction, dismiss: DismissAction) {
self.state = state
self.openUrl = openUrl
self.dismiss = dismiss
}
func handle(action: Action) async {
switch action {
case .onSectionTap(let section):
withAnimation(.easeInOut(duration: 0.3)) {
switch section {
case .blockedTrackers:
state.isTrackersSectionExpanded.toggle()
case .links:
state.isLinksSectionExpanded.toggle()
}
}
case .onBlockedTrackerTap(let domain, let url):
state.presentedBlockedTracker = (domain, url)
case .onBlockedTrackerAlertDismiss:
state.isBlockedTrackerPresented = false
case .onLinkTap(let url):
guard let url = URL(string: url) else { return }
openUrl(url)
case .onGotItTap:
dismiss()
}
}
}
@@ -0,0 +1,46 @@
// 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 Foundation
struct TrackersUIModel: Identifiable, Hashable {
let id = UUID()
let blockedTrackers: [TrackerDomain]
let cleanedLinks: [CleanedLink]
var isEmpty: Bool {
blockedTrackers.isEmpty && cleanedLinks.isEmpty
}
var totalTrackersCount: Int {
blockedTrackers.reduce(0) { $0 + $1.urls.count }
}
var totalLinksCount: Int {
cleanedLinks.count
}
}
struct TrackerDomain: Hashable {
let name: String
let urls: [String]
}
struct CleanedLink: Hashable {
let original: String
let cleaned: String
}
@@ -23,7 +23,7 @@ struct CustomListLeadingSeparator: ViewModifier {
func body(content: Content) -> some View {
content
.alignmentGuide(.listRowSeparatorLeading) { viewDimensions in
return -20.0
-20.0
}
}
}
@@ -15,11 +15,11 @@
// 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
extension Image {
@MainActor
func actionSheetSmallIconModifier() -> some View {
self
.resizable()
@@ -94,9 +94,11 @@ class BackgroundTransitionActionsExecutor: ApplicationServiceDidEnterBackground,
Self.log("Internet connection on start: \(hasAccessToInternetOnStart == true ? "Online" : "Offline")")
do {
backgroundExecutionHandle = try backgroundTaskExecutorProvider().startBackgroundExecution(
callback: callback
).get()
backgroundExecutionHandle = try backgroundTaskExecutorProvider()
.startBackgroundExecution(
callback: callback
)
.get()
Self.log("Handle is returned, background actions in progress")
Self.log("Handle present: \(self.backgroundExecutionHandle != nil)?")
} catch {
@@ -21,8 +21,8 @@ import SwiftUI
/// Keeps `maxElements` in memory. When the limit is reached evicts from the cache the oldest element
final class MemoryCache<Key: Hashable & Sendable, Value: Sendable>: @unchecked Sendable {
private let maxElements: Int
private var dictionary = [Key: Value]()
private var fifoQueue = [Key]()
private var dictionary: [Key: Value] = [:]
private var fifoQueue: [Key] = []
private let queue = DispatchQueue(label: "\(Bundle.defaultIdentifier).MemoryCache", attributes: .concurrent)
+48 -13
View File
@@ -720,12 +720,43 @@ enum L10n {
"To: ...",
comment: "Placeholder for a draft in the conversation view when the draft has no recipients."
)
static let noTrackersDetected = LocalizedStringResource(
"No trackers detected",
comment: "Message details view when no trackers are detected."
)
static let trackerProtection = LocalizedStringResource(
"Tracker protection:",
comment: "Message details title for tracker info row."
)
static func trackersBlocked(count: Int) -> LocalizedStringResource {
.init(
"\(count) trackers blocked",
comment: "Message details number of trackers blocked."
)
}
static func linksCleaned(count: Int) -> LocalizedStringResource {
.init(
"\(count) links cleaned",
comment: "Message details number of links cleaned."
)
}
static let hideDetails = LocalizedStringResource(
"Hide details",
comment: "Title of the button that hide details of a message."
)
}
enum TrackingInfo {
static let description = LocalizedStringResource(
"If you receive an email containing spy pixels (trackers) or tracking links, youll see the shield icon in email details. [Learn more…](https://proton.me/support/email-tracker-protection)",
comment: "Description text explaining the tracking protection."
)
static let original = LocalizedStringResource("Original", comment: "Subtitle for the original link before cleaning.")
static let cleaned = LocalizedStringResource("Cleaned", comment: "Subtitle for link after cleaning.")
}
enum Folders {
static let doesNotExist = LocalizedStringResource(
"Could not move to folder. Folder may have been deleted or moved.",
@@ -910,23 +941,27 @@ enum L10n {
enum AppIcon {
static let buttonTitle = LocalizedStringResource(
"App Icon",
comment: "Title of the button that allows the user to change the apps icon."
comment: "Title of the button that allows the user to change the app's icon."
)
static let primary = LocalizedStringResource(
"Primary",
comment: "Name of the default (primary) app icon shown in the app icon picker."
static let title = LocalizedStringResource(
"App icon",
comment: "Title shown on the app icon selection screen."
)
static let notes = LocalizedStringResource(
"Notes",
comment: "Name of the alternate 'Notes' app icon shown in the app icon picker."
static let discreetToggle = LocalizedStringResource(
"Discreet app icon",
comment: "Toggle label for enabling/disabling discreet app icon feature."
)
static let weather = LocalizedStringResource(
"Weather",
comment: "Name of the alternate 'Weather' app icon shown in the app icon picker."
static let description = LocalizedStringResource(
"Keep the default Proton Mail icon, or disguise it with a more discreet one for extra privacy. Notifications will always show the Proton Mail name and icon. [Learn more…](https://proton.me/support/disguise-app-icon)",
comment: "Description text explaining the app icon feature and privacy implications."
)
static let calculator = LocalizedStringResource(
"Calculator",
comment: "Name of the alternate 'Calculator' app icon shown in the app icon picker."
static let discreet = LocalizedStringResource(
"Discreet",
comment: "Label shown when a discreet app icon is selected."
)
static let defaultIcon = LocalizedStringResource(
"Default",
comment: "Label shown when the default app icon is selected."
)
}
@@ -23,7 +23,7 @@ import class SwiftUI.UIImage
extension Conversation {
func toMailboxItemCellUIModel(selectedIds: Set<Id>, showLocation: Bool) -> MailboxItemCellUIModel {
return MailboxItemCellUIModel(
MailboxItemCellUIModel(
id: id,
conversationID: id,
type: .conversation,
@@ -20,13 +20,14 @@ import UIKit
extension UIApplication {
var keyWindow: UIWindow? {
// Get connected scenes
return self.connectedScenes
self.connectedScenes
// Keep only active scenes, onscreen and visible to the user
.filter { $0.activationState == .foregroundActive }
// Keep only the first `UIWindowScene`
.first(where: { $0 is UIWindowScene })
// Get its associated windows
.flatMap({ $0 as? UIWindowScene })?.windows
.flatMap({ $0 as? UIWindowScene })?
.windows
// Finally, keep only the key window
.first(where: \.isKeyWindow)
}
@@ -18,6 +18,6 @@
extension UInt64 {
// Timestamp 1753883097 = 2025-05-31 01:24:57 UTC
static var timestamp: UInt64 {
1753883097
1_753_883_097
}
}

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