Merge remote-tracking branch 'origin/main' into release/7.6.2
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"project": "apple-mail-new",
|
||||
"locale": "9275202d5eed1f3c51f851d057448fb82680ccd3"
|
||||
"locale": "741b7702a7b3001d3e2c26924f7349feaff51747"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" : {
|
||||
|
||||
|
After Width: | Height: | Size: 17 KiB |
|
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" : {
|
||||
|
||||
|
After Width: | Height: | Size: 118 KiB |
|
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" : {
|
||||
|
||||
|
After Width: | Height: | Size: 80 KiB |
|
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" : {
|
||||
|
||||
|
After Width: | Height: | Size: 573 B |
|
After Width: | Height: | Size: 927 B |
|
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" : {
|
||||
|
||||
|
After Width: | Height: | Size: 638 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
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" : {
|
||||
|
||||
|
After Width: | Height: | Size: 638 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -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()!
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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, you’ll 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 app’s 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
|
||||
}
|
||||
}
|
||||
|
||||